Feat v0.6.10 viewport foundation (#45)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / test-go-pg (push) Successful in 3m14s
CI/CD / build-and-deploy (push) Successful in 1m44s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #45.
This commit is contained in:
2026-04-01 10:42:44 +00:00
committed by xcaliber
parent 617d81e7d4
commit ca3f845c34
19 changed files with 94 additions and 87 deletions

View File

@@ -2,6 +2,47 @@
All notable changes to Armature are documented here. All notable changes to Armature are documented here.
## v0.6.10 — Viewport Foundation
Single layout model. Every surface renders inside one containment chain:
`body → shell → surface`. No dual systems. No transform hacks.
### Changed
- **CSS `zoom` replaces `transform: scale()`**: UI scale (80%175%) now
uses CSS `zoom` on `#surfaceInner` instead of `transform: scale()`.
`zoom` reflows layout correctly — `getBoundingClientRect()` returns
accurate values, eliminating the scale-correction hack in `menu.js`.
Supported in all evergreen browsers (Firefox 126+, June 2024).
- **Single layout root**: `<body>` in `base.html` is the authoritative
flex column layout. `.sw-shell` CSS demoted from viewport-level
container (`height: 100vh`) to fill-parent (`height: 100%`).
Safe-area insets moved from `.sw-shell` to `<body>`.
- **Banner single source of truth**: Template banners measure their own
height via inline `<script>` and set `--banner-top-height` /
`--banner-bottom-height` CSS variables. Removed `--banner-h: 28px`
fixed variable. `ShellBanner` Preact component's `useEffect`
measurement removed (dead code — no surface imports `AppShell`).
- **`sw-shell__banner` position**: Changed from `position: fixed` to
`position: static` — template banners are in-flow elements.
- **`sw-shell__body` padding**: Removed `padding-top/bottom` for
banner offsets — unnecessary with in-flow banners.
- **Extension surfaces `100vh``100%`**: `chat-app`, `chat-loading`,
`surface-dashboard` now use `height: 100%` to inherit from the
extension mount container (like Notes). Fixes overflow behind banners.
- **`100vh``100dvh` fallbacks**: All viewport-height declarations
use `height: 100vh; height: 100dvh;` pattern for correct behavior on
mobile browsers. Affects: `base.html`, `sw-login.css`,
`workflow.html`, `workflow-landing.html`, `primitives.css`,
`git-board/css/main.css`.
- **`sw.shell.getScale()` deprecated**: Returns `1` always — CSS `zoom`
handles layout reflow without manual correction.
### Deprecated
- `src/js/sw/shell/app-shell.js`, `app.js`, `surface-viewport.js`
no surface imports these. Layout root is `<body>` in `base.html`.
## v0.6.9 — Session Lifetime Config ## v0.6.9 — Session Lifetime Config
Admin-configurable session durations, "keep me logged in" opt-in, and Admin-configurable session durations, "keep me logged in" opt-in, and

View File

@@ -45,19 +45,11 @@
Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout, Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout,
config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details. config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details.
### v0.6.10 — Viewport Foundation ### v0.6.10 — Viewport Foundation
Establish a single, correct layout model. Every surface must render inside one Shipped. Single layout model: `body → shell → surface`. CSS `zoom` replaces
containment chain: `body → shell → surface`. No dual systems. No transform hacks. `transform: scale()`. `100dvh` fallbacks everywhere. Extension surfaces use
`100%` instead of `100vh`. Dead shell layout code deprecated. See CHANGELOG.md.
| Step | Description |
|------|-------------|
| Unify shell layout | Remove the inline `<style>` block from `base.html` that creates a parallel column layout. Make `base.html` render a single `<div class="sw-shell">` wrapper. Banners, announcements, footer, and surface all live inside the Preact shell's flex column OR the template column — pick one, kill the other. **Recommendation**: keep the Go template column (it works without JS) and delete `sw-shell.css`'s parallel layout. The Preact `AppShell` becomes a component that renders *inside* `#surfaceInner`, not a viewport-level container. |
| Banner single source of truth | One banner rendering path. Template banners (`base.html:72-107`) set `--banner-top-height` and `--banner-bottom-height` via a `<script>` that measures after DOMContentLoaded. Remove `ShellBanner`'s JS measurement. Surfaces use `calc(100vh - var(--banner-top-height) - var(--banner-bottom-height))` or pure flex containment — not both. |
| Replace transform-scale with CSS `zoom` | CSS `zoom` is now supported in all evergreen browsers (including Firefox 126+, June 2024). `zoom` actually reflows layout — `getBoundingClientRect` returns correct values, scroll containers work, pointer events are accurate. Remove all `transform: scale()` code from `base.html`, `appearance.js`, `menu.js` scale-correction hacks. Apply `zoom` on `#surfaceInner`. The `sw.shell.getScale()` API becomes a no-op returning 1 (or reads `getComputedStyle().zoom`). |
| Fix `100vh``100dvh` | Replace all `height: 100vh` with `height: 100dvh` (fallback: `height: 100vh; height: 100dvh;`). Affects: `sw-shell.css`, `sw-login.css`, `chat/css/main.css`. |
| Chat `100vh``100%` | `.chat-app` and `.chat-loading` use `height: 100%` like Notes. They inherit their height from the extension mount container which is already correctly sized. |
| Validation | Manual test matrix: banner on/off × scale 80%/100%/150% × mobile/desktop × light/dark. Every surface (admin, settings, team-admin, docs, welcome, notes, chat). No overflow, no gaps, menus positioned correctly. |
### v0.6.11 — CSS Deduplication ### v0.6.11 — CSS Deduplication
@@ -124,7 +116,7 @@ Make the UI machine-auditable so Claude Code can run an automated survey.
``` ```
v0.6.9 Session Lifetime Config ✅ SHIPPED v0.6.9 Session Lifetime Config ✅ SHIPPED
v0.6.10 Viewport Foundation ← Everything depends on correct containment v0.6.10 Viewport Foundation ✅ SHIPPED
v0.6.11 CSS Deduplication ← Can't audit styling until there's one source of truth v0.6.11 CSS Deduplication ← Can't audit styling until there's one source of truth

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.6.7Native mTLS ## Current: v0.6.10Viewport Foundation
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension. storage, realtime, and ops are kernel primitives. Everything else is an extension.

View File

@@ -1 +1 @@
0.6.9 0.6.10

View File

@@ -12,7 +12,7 @@
.chat-app { .chat-app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
@@ -22,7 +22,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100%;
} }
.chat-body { .chat-body {

View File

@@ -9,7 +9,7 @@
.surface-dashboard { .surface-dashboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
overflow: hidden; overflow: hidden;
} }

View File

@@ -258,6 +258,7 @@
width: 100%; width: 100%;
max-width: 680px; max-width: 680px;
max-height: calc(100vh - 80px); max-height: calc(100vh - 80px);
max-height: calc(100dvh - 80px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.3); box-shadow: 0 8px 32px rgba(0,0,0,0.3);

View File

@@ -27,12 +27,9 @@
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}} {{end}}
<style> <style>
:root { body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0); }
--banner-h: 28px;
}
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.surface { flex: 1; min-height: 0; overflow: hidden; } .surface { flex: 1; min-height: 0; overflow: hidden; }
.surface-inner { width: 100%; height: 100%; transform-origin: top left; } .surface-inner { width: 100%; height: 100%; }
.banner { flex-shrink: 0; } .banner { flex-shrink: 0; }
</style> </style>
<meta name="theme-color" content="#0e0e10" id="metaThemeColor"> <meta name="theme-color" content="#0e0e10" id="metaThemeColor">
@@ -52,7 +49,7 @@
} catch(e) {} } catch(e) {}
})(); })();
// Early appearance — apply scale from localStorage on all surfaces. // Early appearance — apply scale from localStorage on all surfaces.
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport. // CSS zoom reflows layout correctly (no getBoundingClientRect hacks needed).
(function() { (function() {
try { try {
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}'); var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
@@ -60,7 +57,7 @@
var s = p.scale / 100; var s = p.scale / 100;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('surfaceInner'); var el = document.getElementById('surfaceInner');
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; } if (el) { el.style.zoom = s; }
}); });
} }
} catch(e) {} } catch(e) {}
@@ -69,9 +66,12 @@
</head> </head>
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}"> <body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
<script>
(function(){ var b=document.querySelector('.banner-top.active'); if(b) document.documentElement.style.setProperty('--banner-top-height',b.offsetHeight+'px'); })();
</script>
{{end}} {{end}}
{{if .Message.Visible}} {{if .Message.Visible}}
@@ -101,9 +101,12 @@
{{end}} {{end}}
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
<script>
(function(){ var b=document.querySelector('.banner-bottom.active'); if(b) document.documentElement.style.setProperty('--banner-bottom-height',b.offsetHeight+'px'); })();
</script>
{{end}} {{end}}
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">

View File

@@ -18,6 +18,7 @@
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -13,7 +13,7 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); } body { font-family: var(--font); background: var(--bg); color: var(--text); }
.wf-shell { display: flex; flex-direction: column; height: 100vh; } .wf-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
.wf-header { .wf-header {
padding: 16px 24px; padding: 16px 24px;

View File

@@ -491,6 +491,7 @@ select option { background: var(--bg-surface); color: var(--text); }
width: calc(100vw - 32px); width: calc(100vw - 32px);
max-width: 100%; max-width: 100%;
max-height: calc(100vh - 64px); max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
} }
/* Toast: full width, centered */ /* Toast: full width, centered */

View File

@@ -4,7 +4,7 @@
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
/* ── Layout ──────────────────────────────────── */ /* ── Layout ──────────────────────────────────── */
.login-shell { display: flex; flex-direction: column; height: 100vh; } .login-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
.login-main { display: flex; flex: 1; min-height: 0; } .login-main { display: flex; flex: 1; min-height: 0; }
/* ── Hero Panel (left) ─────────────────────── */ /* ── Hero Panel (left) ─────────────────────── */

View File

@@ -3,26 +3,23 @@
fixed banners, announcement, surface, and footer. fixed banners, announcement, surface, and footer.
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
/* v0.6.10: .sw-shell is no longer a viewport-level container.
The single layout root is <body> in base.html.
.sw-shell fills its parent if any surface uses it. */
.sw-shell { .sw-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg); background: var(--bg);
/* Safe-area insets for notched devices */
padding-top: env(safe-area-inset-top, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
} }
/* ── Banner (fixed top / bottom — identical component) ── */ /* ── Banner (fixed top / bottom — identical component) ── */
/* v0.6.10: banners are in-flow (template renders them), not fixed. */
.sw-shell__banner { .sw-shell__banner {
position: fixed; position: static;
left: 0;
right: 0;
z-index: 900; z-index: 900;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -60,13 +57,12 @@
/* ── Shell body — insets for fixed banners ─── */ /* ── Shell body — insets for fixed banners ─── */
/* v0.6.10: template banners are in-flow — no padding offset needed. */
.sw-shell__body { .sw-shell__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
padding-top: var(--banner-top-height, 0px);
padding-bottom: var(--banner-bottom-height, 0px);
} }
/* ── Announcement bar ─────────────────────── */ /* ── Announcement bar ─────────────────────── */

View File

@@ -16,21 +16,14 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
const selectableItems = items.filter(i => !i.divider && !i.disabled); const selectableItems = items.filter(i => !i.divider && !i.disabled);
// Position relative to anchor // Position relative to anchor (CSS zoom reflows layout correctly)
// When the surface uses CSS transform: scale(), getBoundingClientRect()
// returns scaled coordinates but position:fixed uses viewport coordinates.
// Dividing by the scale factor converts back to true viewport pixels.
useEffect(() => { useEffect(() => {
if (!open || !anchor) return; if (!open || !anchor) return;
const scale = window.sw?.shell?.getScale?.() || 1; const rect = typeof anchor.getBoundingClientRect === 'function'
const raw = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect() ? anchor.getBoundingClientRect()
: anchor; : anchor;
const rect = scale !== 1 const vw = window.innerWidth;
? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale } const vh = window.innerHeight;
: raw;
const vw = window.innerWidth / scale;
const vh = window.innerHeight / scale;
let top, left; let top, left;
const [vDir, hDir] = direction.split('-'); const [vDir, hDir] = direction.split('-');

View File

@@ -123,15 +123,8 @@ export async function boot() {
// Shell — layout utilities (decouples surface code from shell DOM) // Shell — layout utilities (decouples surface code from shell DOM)
const _shell = { const _shell = {
/** CSS transform scale on #surfaceInner (appearance zoom). */ /** @deprecated v0.6.10 — CSS zoom handles reflow; no scale correction needed. */
getScale() { getScale() { return 1; },
const el = document.getElementById('surfaceInner');
if (!el) return 1;
const t = getComputedStyle(el).transform;
if (!t || t === 'none') return 1;
const m = t.match(/matrix\(([^,]+)/);
return m ? parseFloat(m[1]) || 1 : 1;
},
/** Topbar — set after dynamic import below */ /** Topbar — set after dynamic import below */
Topbar: null, Topbar: null,
}; };

View File

@@ -1,4 +1,6 @@
/** /** @deprecated v0.6.10 — No surface imports AppShell. Layout root is <body> in base.html.
* Kept for reference; will be removed in a future version.
*
* AppShell — Layer 1 layout scaffold * AppShell — Layer 1 layout scaffold
* *
* ┌─────────────────────────────────┐ ← fixed banner (top, optional) * ┌─────────────────────────────────┐ ← fixed banner (top, optional)
@@ -22,20 +24,10 @@
const { html } = window; const { html } = window;
const { useState, useEffect, useRef, useReducer } = hooks; const { useState, useEffect, useRef, useReducer } = hooks;
/** @deprecated v0.6.10 — Banner height is measured by template scripts in base.html. */
function ShellBanner({ position, text, variant = 'info' }) { function ShellBanner({ position, text, variant = 'info' }) {
const ref = useRef(null);
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
useEffect(() => {
if (ref.current) {
const h = ref.current.offsetHeight + 'px';
document.documentElement.style.setProperty(prop, h);
}
return () => document.documentElement.style.setProperty(prop, '0px');
}, [text, prop]);
return html` return html`
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}> <div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}">
<span class="sw-shell__banner-text">${text}</span> <span class="sw-shell__banner-text">${text}</span>
</div> </div>
`; `;

View File

@@ -1,4 +1,6 @@
/** /** @deprecated v0.6.10 — No surface imports App. Layout root is <body> in base.html.
* Kept for reference; will be removed in a future version.
*
* App — root component composing the full shell tree * App — root component composing the full shell tree
* *
* ┌───────────────────────────────┐ * ┌───────────────────────────────┐

View File

@@ -1,4 +1,5 @@
/** /** @deprecated v0.6.10 — No surface imports SurfaceViewport. Layout root is <body> in base.html.
*
* SurfaceViewport — container where the active surface renders * SurfaceViewport — container where the active surface renders
* *
* Deliberately thin. Error boundaries and * Deliberately thin. Error boundaries and

View File

@@ -37,20 +37,11 @@ export function AppearanceSection() {
p.scale = scale; p.scale = scale;
localStorage.setItem('sb-appearance', JSON.stringify(p)); localStorage.setItem('sb-appearance', JSON.stringify(p));
// Apply scale to content area only (not shell/nav) // Apply scale to content area only (not shell/nav).
// CSS zoom reflows layout correctly — no getBoundingClientRect hacks needed.
const inner = document.getElementById('surfaceInner'); const inner = document.getElementById('surfaceInner');
if (inner) { if (inner) {
if (scale !== 100) { inner.style.zoom = scale !== 100 ? (scale / 100) : '';
const s = scale / 100;
inner.style.transform = `scale(${s})`;
inner.style.transformOrigin = 'top left';
inner.style.width = (100 / s) + '%';
inner.style.height = (100 / s) + '%';
} else {
inner.style.transform = '';
inner.style.width = '';
inner.style.height = '';
}
} }
sw.emit('toast', { message: 'Appearance saved', variant: 'success' }); sw.emit('toast', { message: 'Appearance saved', variant: 'success' });