openvibers/openvibe.design Commits
/native: from the web to Android and iOS, without a frame (#174)
Changes
nnew file mode 100644@@@ -0,0 +1,87 @@++/* /native: one screen at three sizes, the four layers, size classes, the road. Theme tokens only. */++.nv-sec { margin-top: clamp(28px, 5vw, 48px); }++.nv-sec h2 { margin: 0 0 6px; font-size: clamp(1.25rem, 2.4vw, 1.6rem); letter-spacing: -.02em; }++.nv [hidden] { display: none !important; }++++/* The three frames: the same parts, a different arrangement. */++.nv-frames { display: grid; grid-template-columns: 150px minmax(0, 1fr) minmax(0, 1.5fr); gap: 18px; align-items: end; margin-top: 18px; }++.nv-frame { margin: 0; display: grid; gap: 8px; min-width: 0; }++.nv-frame figcaption { font-size: 13px; color: var(--text-muted, #7386a3); text-align: center; }++.nv-frame figcaption b { color: var(--text-primary, #e6edf7); margin-right: 4px; }++.nv-screen { position: relative; overflow: hidden; display: flex; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c);++ box-shadow: 0 18px 40px rgba(0, 0, 0, .28), inset 0 0 0 4px color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }++.nv-compact .nv-screen { flex-direction: column; aspect-ratio: 9 / 19; border-radius: 26px; padding: 18px 8px 0; }++.nv-medium .nv-screen { aspect-ratio: 4 / 3; border-radius: 20px; padding: 10px; gap: 8px; }++.nv-expanded .nv-screen { aspect-ratio: 16 / 10; border-radius: 12px; padding: 10px; gap: 10px; }++.nv-top { display: flex; align-items: center; height: 18px; font-size: 10px; color: var(--text-primary, #e6edf7); }++.nv-list { display: grid; gap: 5px; margin-top: 6px; }++.nv-row { display: flex; align-items: center; gap: 6px; height: 18px; }++.nv-row i, .nv-tile i { flex: none; width: 16px; height: 16px; border-radius: 5px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }++.nv-row b { height: 6px; width: var(--w, 70%); border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }++.nv-fab { position: absolute; right: 8px; bottom: 34px; padding: 5px 8px; border-radius: 9px; font-size: 9px; font-weight: 800; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); box-shadow: 0 6px 14px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }++.nv-tabs { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around; align-items: center; height: 26px; border-top: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }++.nv-tabs i, .nv-rail i { width: 12px; height: 12px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #fff) 22%, transparent); }++.nv-tabs i.on, .nv-rail i.on { background: var(--accent, #3b82f6); }++.nv-rail { display: grid; align-content: start; justify-items: center; gap: 12px; width: 26px; padding-top: 4px; border-right: 1px solid var(--border, #1f2d47); }++.nv-rail .nv-new { width: 18px; height: 18px; border-radius: 6px; background: var(--accent, #3b82f6); }++.nv-main { flex: 1; min-width: 0; display: grid; align-content: start; gap: 6px; }++.nv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 6px; }++.nv-tile { display: grid; gap: 4px; padding: 4px; border-radius: 6px; background: var(--bg-secondary, #0f1729); }++.nv-tile i { width: 100%; height: auto; aspect-ratio: 4 / 3; }++.nv-tile b { height: 5px; width: 70%; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }++.nv-side { display: grid; align-content: start; gap: 6px; width: 92px; font-size: 9.5px; color: var(--text-secondary, #96a7c2); }++.nv-side span { padding: 3px 6px; border-radius: 99px; }++.nv-side .on { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--text-primary, #e6edf7); }++.nv-new-w { justify-self: start; padding: 5px 9px !important; border-radius: 9px !important; border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); font-weight: 800; }++.nv-detail { display: grid; align-content: start; gap: 6px; width: 30%; padding: 8px; border-radius: 8px; background: var(--bg-secondary, #0f1729); }++.nv-detail i { width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }++.nv-detail b { height: 6px; width: 80%; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }++.nv-detail b + b { width: 55%; }++.nv-btn { height: 12px; width: 50%; border-radius: 99px; background: var(--accent, #3b82f6); }++++.nv-layers { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }++.nv-layer { display: grid; align-content: start; gap: 6px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: 14px; line-height: 1.5; transition: border-color .2s; }++.nv-layer:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); }++.nv-layer b { font-size: 16px; color: var(--text-primary, #e6edf7); }++.nv-n { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 9px; font-weight: 800; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }++++.nv-table { display: grid; margin-top: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; }++.nv-tr { display: grid; grid-template-columns: 110px 130px repeat(3, minmax(0, 1fr)); gap: 12px; padding: 12px 16px; font-size: 14px; }++.nv-tr + .nv-tr { border-top: 1px solid var(--border, #1f2d47); }++.nv-th { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: var(--bg-card, #131c2e); }++++.nv-dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 0; }++.nv-dl div { padding: 14px 16px; border-radius: 14px; background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); }++.nv-dl dt { font-weight: 800; margin-bottom: 4px; }++.nv-dl dd { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }++++.nv-road { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 0; counter-reset: road; }++.nv-road li { position: relative; display: grid; grid-template-columns: 76px minmax(0, 1fr); column-gap: 16px; padding: 0 0 18px 0; }++.nv-road li::before { content: ""; position: absolute; left: 37px; top: 26px; bottom: 0; width: 2px; background: var(--border, #1f2d47); }++.nv-road li:last-child::before { display: none; }++.nv-state { grid-row: span 2; align-self: start; justify-self: center; padding: 3px 10px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); position: relative; }++.nv-road li[data-state="now"] .nv-state { color: var(--on-accent, #fff); background: var(--accent, #3b82f6); border-color: transparent; }++.nv-road li[data-state="next"] .nv-state { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }++.nv-road b { font-size: 16px; }++.nv-road p { margin: 4px 0 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary, #96a7c2); max-width: 70ch; }++++.nv-code { margin-top: 12px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); overflow-x: auto; font-size: 13px; }++.nv-check { margin: 12px 0 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px; }++.nv-check li { position: relative; padding-left: 28px; font-size: 14.5px; line-height: 1.5; }++.nv-check li::before { content: ""; position: absolute; left: 0; top: 2px; width: 18px; height: 18px; border-radius: 6px; border: 2px solid var(--accent, #3b82f6); }++.nv-more { margin-top: 18px; font-size: 14px; }++++@media (max-width: 900px) {++ .nv-frames { grid-template-columns: 120px minmax(0, 1fr); }++ .nv-expanded { grid-column: 1 / -1; }++ .nv-layers { grid-template-columns: repeat(2, minmax(0, 1fr)); }++}++@media (max-width: 640px) {++ .nv-frames { grid-template-columns: 110px minmax(0, 1fr); gap: 12px; }++ .nv-layers, .nv-dl, .nv-check { grid-template-columns: minmax(0, 1fr); }++ .nv-tr { grid-template-columns: minmax(0, 1fr); gap: 4px; }++ .nv-th { display: none; }++ .nv-tr span[data-label]::before { content: attr(data-label) " · "; color: var(--text-muted, #7386a3); }++ .nv-road li { grid-template-columns: 64px minmax(0, 1fr); column-gap: 12px; }++ .nv-road li::before { left: 31px; }++}@@@ -37,6 +37,7 @@ function publicPages(themes = []) {{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },++ { path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },@@@ -88,6 +88,7 @@ function createPageRoutes(ctx) {});// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));++ r.get('/native', (req, res) => page(res, 200, require('../views/native').nativePage(), req.viewer));r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));@@@ -36,6 +36,7 @@ const NAV = [{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },++ { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-display', more: true },{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },];nnew file mode 100644@@@ -0,0 +1,107 @@++'use strict';++/**++ * /native: from the web to Android and iOS, without a frame (the owner, 2026-11-07: "make sure OpenVibe.design++ * supports Android and iOS apps … seamless"; 10-29: real apps, not web views). One page that ties the system++ * together: one screen at three sizes, the four layers every platform shares (tokens, components, patterns, icons),++ * the size classes, what each platform does its own way, the road from here, and a checklist for a page that is++ * about to become an app screen.++ *++ * Server-rendered, no script: the three frames are markup and CSS (public/css/native.css).++ *++ * nativePage()++ * LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST the content, so tests and the Markdown twin read the same words++ */++const { layout, esc } = require('../render/layout');++++const LAYERS = Object.freeze([++ { name: 'Tokens', href: '/tokens', what: 'Colour, type, space, radius and motion as data (DTCG JSON). Android gets a Compose theme, iOS gets OvTokens and OvScale, the web gets CSS variables: one source, three files.' },++ { name: 'Components', href: '/components', what: 'Each part described by what it is: its pieces, the tokens they wear, its states and how a screen reader hears it, with code for the web, Jetpack Compose and SwiftUI.' },++ { name: 'Patterns', href: '/patterns', what: 'The rules production taught, each with the bug behind it, a live demo, and SwiftUI and Compose specs generated from the same tokens.' },++ { name: 'Icons', href: '/icons', what: 'One glyph set for every platform: SVG and a font for the web, vector drawables for Android, PDF vectors for iOS, the same names everywhere.' },++]);++++const CLASSES = Object.freeze([++ { name: 'Compact', width: 'under 600 px', nav: 'A bottom tab bar, five places at most', add: 'A floating New over a sheet', detail: 'Full screen; Back returns to the list' },++ { name: 'Medium', width: '600 to 839 px', nav: 'A navigation rail of icons', add: 'New at the top of the rail', detail: 'Side by side from 720 px' },++ { name: 'Expanded', width: '840 px and wider', nav: 'A sidebar with words', add: 'New at the top of the sidebar', detail: 'The list and the open item together' },++]);++++const PLATFORM = Object.freeze([++ ['Back', 'Closes the top sheet or menu first, then leaves the screen. Android’s predictive back shows where it goes; on iOS a sheet swipes down and the edge swipe goes back; on the web, Back closes the menu before it leaves the page.'],++ ['Safe areas', 'Nothing sits under the notch, the home indicator or a system bar: bars pad themselves by the insets, content scrolls under them.'],++ ['Text size', 'Dynamic Type and Android font scale up to 200%. Rows wrap and grow; nothing clips or truncates a name to an ellipsis it did not need.'],++ ['Motion', 'Durations from the motion tokens (180, 260, 360 ms); reduced motion removes entrances and keeps state changes as instant swaps.'],++ ['Touch', 'Every target is 44 px (48 dp on Android). A press becomes a drag only after 6 px.'],++ ['Haptics', 'A light tap when something is confirmed or sent (apps only); never for scrolling or ambient changes.'],++ ['Your look', 'The theme you wear on openvibe.design reaches apps through the same tokens (GET /api/me/theme), so the app looks like your sites.'],++ ['Notifications', 'Web push on the web; FCM on Android and APNs on iOS through OpenVibe.Network, with the same quiet hours and the same Do not disturb.'],++]);++++const ROAD = Object.freeze([++ { step: 'Phones first on the web', state: 'now', text: 'Every site at 390 px: no sideways scroll, 44 px targets, sheets instead of popovers, Back that closes them. Measured site by site.' },++ { step: 'Install from the browser', state: 'next', text: 'A manifest and an offline shell for each site people use daily, so it opens like an app and the last screen shows without a network.' },++ { step: 'Android', state: 'planned', text: 'A Compose shell. Native screens first where the web cannot reach: messages, the Live player, the camera, and Link’s phone automation (accessibility). Other screens render kit documents with native views. A direct APK first; Google Play when the account is opened.' },++ { step: 'iOS', state: 'planned', text: 'A SwiftUI shell with the same screens and the same documents. The App Store when the account is opened.' },++ { step: 'Desktop', state: 'planned', text: 'The OpenVibe app: Codes and Link in one native shell, with your machines, your agent and your files.' },++]);++++const CHECKLIST = Object.freeze([++ 'It is made of kit components, not one-off markup, so each part has a native twin.',++ 'Every colour, size and duration is a token; no literal hex in a rule.',++ 'At 390 px nothing scrolls sideways and nothing is hidden to make it fit.',++ 'Adding is one button; its choices are in a sheet.',++ 'Back closes what is open before it leaves.',++ 'It reads well at 200% text.',++ 'It works with reduced motion, and with JavaScript off on the web.',++ 'Its data comes from a documented API, so an app can ask for the same thing.',++]);++++/** One screen, drawn at three sizes: the same parts, a different arrangement. Markup only. */++function frames() {++ const rows = (n) => Array.from({ length: n }, (_, i) => `<span class="nv-row"><i></i><b style="--w:${60 + ((i * 17) % 30)}%"></b></span>`).join('');++ const tiles = (n) => Array.from({ length: n }, () => '<span class="nv-tile"><i></i><b></b></span>').join('');++ return `<div class="nv-frames" role="img" aria-label="The same files screen on a phone, a tablet and a computer: bottom tabs and a floating New on the phone, a rail on the tablet, a sidebar with the open item beside the list on the computer">++<figure class="nv-frame nv-compact"><div class="nv-screen"><span class="nv-top"><b>My files</b></span><div class="nv-list">${rows(6)}</div><span class="nv-fab">+ New</span><span class="nv-tabs"><i></i><i class="on"></i><i></i><i></i></span></div><figcaption><b>Compact</b> a phone</figcaption></figure>++<figure class="nv-frame nv-medium"><div class="nv-screen"><span class="nv-rail"><i class="nv-new"></i><i class="on"></i><i></i><i></i><i></i></span><div class="nv-main"><span class="nv-top"><b>My files</b></span><div class="nv-grid">${tiles(6)}</div></div></div><figcaption><b>Medium</b> a tablet or a fold</figcaption></figure>++<figure class="nv-frame nv-expanded"><div class="nv-screen"><span class="nv-side"><span class="nv-new-w">+ New</span><span class="on">My files</span><span>Shared</span><span>Backups</span></span><div class="nv-main"><span class="nv-top"><b>My files</b></span><div class="nv-grid">${tiles(8)}</div></div><span class="nv-detail"><i></i><b></b><b></b><span class="nv-btn"></span></span></div><figcaption><b>Expanded</b> a computer</figcaption></figure>++</div>`;++}++++function nativePage() {++ const body = `<header class="ds-page-h"><p class="ds-kicker">Apps</p><h1>From the web to Android and iOS, without a frame</h1>++<p class="ds-lede">An OpenVibe app is never a web page in a frame. Every platform draws the same parts with its own views: the tokens, the components, the patterns and the icons on this site. The web stays the source of truth, and an app follows it screen by screen.</p></header>++<section class="nv-sec" aria-labelledby="nv-one"><h2 id="nv-one">One screen, three sizes</h2>++<p class="ds-lede">Not three designs: one set of parts, arranged by how much room there is.</p>++${frames()}</section>++<section class="nv-sec" aria-labelledby="nv-layers"><h2 id="nv-layers">Four layers every platform shares</h2>++<div class="nv-layers">${LAYERS.map((l, i) => `<a class="nv-layer" href="${esc(l.href)}"><span class="nv-n">${i + 1}</span><b>${esc(l.name)}</b><span>${esc(l.what)}</span></a>`).join('')}</div></section>++<section class="nv-sec" aria-labelledby="nv-classes"><h2 id="nv-classes">Size classes</h2>++<p class="ds-lede">The platforms’ own window classes. The web’s bar folds at 760 px today; apps follow these.</p>++<div class="nv-table" role="table" aria-label="Size classes">++<div class="nv-tr nv-th" role="row"><span role="columnheader">Class</span><span role="columnheader">Width</span><span role="columnheader">Places</span><span role="columnheader">Adding</span><span role="columnheader">An open item</span></div>++${CLASSES.map((c) => `<div class="nv-tr" role="row"><span role="cell"><b>${esc(c.name)}</b></span><span role="cell" data-label="Width">${esc(c.width)}</span><span role="cell" data-label="Places">${esc(c.nav)}</span><span role="cell" data-label="Adding">${esc(c.add)}</span><span role="cell" data-label="An open item">${esc(c.detail)}</span></div>`).join('')}++</div></section>++<section class="nv-sec" aria-labelledby="nv-platform"><h2 id="nv-platform">What each platform does its own way</h2>++<dl class="nv-dl">${PLATFORM.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>++<section class="nv-sec" aria-labelledby="nv-road"><h2 id="nv-road">The road from here</h2>++<ol class="nv-road">${ROAD.map((r) => `<li data-state="${esc(r.state)}"><span class="nv-state">${esc(r.state === 'now' ? 'Now' : r.state === 'next' ? 'Next' : 'Planned')}</span><b>${esc(r.step)}</b><p>${esc(r.text)}</p></li>`).join('')}</ol></section>++<section class="nv-sec" aria-labelledby="nv-screens"><h2 id="nv-screens">A screen as data</h2>++<p class="ds-lede">A screen can travel as a tree of kit components. The web draws it with the kit; an app draws the same tree with Compose or SwiftUI, so a new screen reaches every platform without a release.</p>++<pre class="nv-code"><code>{ "type": "rail", "title": "Trending",++ "children": [++ { "type": "card", "title": "A rainy night walk", "meta": "23 views this week", "href": "https://openvibe.video/v/6119" }++ ] }</code></pre></section>++<section class="nv-sec" aria-labelledby="nv-check"><h2 id="nv-check">Ready to become an app screen?</h2>++<ul class="nv-check">${CHECKLIST.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>++<p class="nv-more"><a href="/components?p=android">Components for Android</a> · <a href="/components?p=ios">Components for iOS</a> · <a href="/tokens">Tokens</a> · <a href="/patterns">Patterns</a></p></section>`;++ return layout({++ title: 'Apps for Android and iOS: the OpenVibe design system, natively',++ description: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.',++ path: '/native',++ active: '/native',++ styles: ['css/native.css'],++ body: `<div class="ds-wrap nv">${body}</div>`,++ });++}++++module.exports = { nativePage, LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST };nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// /native (11-07): the road from the web to Android and iOS, without a frame. The page renders whole without++// JavaScript, links each shared layer, says every size class, every platform difference and every road step, is in++// the sitemap, and is reachable from the bar's More.++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST } = require('../server/views/native');++const { NAV } = require('../server/render/layout');++++(async () => {++ const t = await boot();++ try {++ await check('the page says the whole road, with every layer linked', async () => {++ const r = await t.get('/native');++ assert.equal(r.status, 200);++ assert.match(r.text, /without a frame/);++ for (const l of LAYERS) assert.ok(r.text.includes(`href="${l.href}"`), `${l.name} is linked`);++ for (const c of CLASSES) assert.ok(r.text.includes(c.name) && r.text.includes(c.width), c.name);++ for (const [k] of PLATFORM) assert.ok(r.text.includes(`<dt>${k}</dt>`), k);++ for (const s of ROAD) assert.ok(r.text.includes(s.step), s.step);++ assert.equal((r.text.match(/<li>/g) || []).length >= CHECKLIST.length, true, 'the checklist');++ assert.ok(r.text.includes('/css/native.css'), 'its stylesheet');++ assert.ok(!/<script[^>]*native/.test(r.text), 'no page script: whole without JavaScript');++ });++ await check('it is in the sitemap and under More', async () => {++ const map = await t.get('/sitemap.xml');++ assert.match(map.text, /\/native</);++ assert.ok(NAV.some((l) => l.href === '/native' && l.more), 'More lists it');++ const css = await t.get('/css/native.css');++ assert.equal(css.status, 200);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+/* /native: one screen at three sizes, the four layers, size classes, the road. Theme tokens only. */
+.nv-sec { margin-top: clamp(28px, 5vw, 48px); }
+.nv-sec h2 { margin: 0 0 6px; font-size: clamp(1.25rem, 2.4vw, 1.6rem); letter-spacing: -.02em; }
+.nv [hidden] { display: none !important; }
+
+/* The three frames: the same parts, a different arrangement. */
+.nv-frames { display: grid; grid-template-columns: 150px minmax(0, 1fr) minmax(0, 1.5fr); gap: 18px; align-items: end; margin-top: 18px; }
+.nv-frame { margin: 0; display: grid; gap: 8px; min-width: 0; }
+.nv-frame figcaption { font-size: 13px; color: var(--text-muted, #7386a3); text-align: center; }
+.nv-frame figcaption b { color: var(--text-primary, #e6edf7); margin-right: 4px; }
+.nv-screen { position: relative; overflow: hidden; display: flex; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c);
+ box-shadow: 0 18px 40px rgba(0, 0, 0, .28), inset 0 0 0 4px color-mix(in srgb, var(--text-primary, #fff) 4%, transparent); }
+.nv-compact .nv-screen { flex-direction: column; aspect-ratio: 9 / 19; border-radius: 26px; padding: 18px 8px 0; }
+.nv-medium .nv-screen { aspect-ratio: 4 / 3; border-radius: 20px; padding: 10px; gap: 8px; }
+.nv-expanded .nv-screen { aspect-ratio: 16 / 10; border-radius: 12px; padding: 10px; gap: 10px; }
+.nv-top { display: flex; align-items: center; height: 18px; font-size: 10px; color: var(--text-primary, #e6edf7); }
+.nv-list { display: grid; gap: 5px; margin-top: 6px; }
+.nv-row { display: flex; align-items: center; gap: 6px; height: 18px; }
+.nv-row i, .nv-tile i { flex: none; width: 16px; height: 16px; border-radius: 5px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
+.nv-row b { height: 6px; width: var(--w, 70%); border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }
+.nv-fab { position: absolute; right: 8px; bottom: 34px; padding: 5px 8px; border-radius: 9px; font-size: 9px; font-weight: 800; color: var(--on-accent, #fff); background: var(--accent, #3b82f6); box-shadow: 0 6px 14px color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
+.nv-tabs { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around; align-items: center; height: 26px; border-top: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); }
+.nv-tabs i, .nv-rail i { width: 12px; height: 12px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #fff) 22%, transparent); }
+.nv-tabs i.on, .nv-rail i.on { background: var(--accent, #3b82f6); }
+.nv-rail { display: grid; align-content: start; justify-items: center; gap: 12px; width: 26px; padding-top: 4px; border-right: 1px solid var(--border, #1f2d47); }
+.nv-rail .nv-new { width: 18px; height: 18px; border-radius: 6px; background: var(--accent, #3b82f6); }
+.nv-main { flex: 1; min-width: 0; display: grid; align-content: start; gap: 6px; }
+.nv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 6px; }
+.nv-tile { display: grid; gap: 4px; padding: 4px; border-radius: 6px; background: var(--bg-secondary, #0f1729); }
+.nv-tile i { width: 100%; height: auto; aspect-ratio: 4 / 3; }
+.nv-tile b { height: 5px; width: 70%; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }
+.nv-side { display: grid; align-content: start; gap: 6px; width: 92px; font-size: 9.5px; color: var(--text-secondary, #96a7c2); }
+.nv-side span { padding: 3px 6px; border-radius: 99px; }
+.nv-side .on { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--text-primary, #e6edf7); }
+.nv-new-w { justify-self: start; padding: 5px 9px !important; border-radius: 9px !important; border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); font-weight: 800; }
+.nv-detail { display: grid; align-content: start; gap: 6px; width: 30%; padding: 8px; border-radius: 8px; background: var(--bg-secondary, #0f1729); }
+.nv-detail i { width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; background: color-mix(in srgb, var(--accent, #3b82f6) 26%, transparent); }
+.nv-detail b { height: 6px; width: 80%; border-radius: 3px; background: color-mix(in srgb, var(--text-primary, #fff) 18%, transparent); }
+.nv-detail b + b { width: 55%; }
+.nv-btn { height: 12px; width: 50%; border-radius: 99px; background: var(--accent, #3b82f6); }
+
+.nv-layers { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
+.nv-layer { display: grid; align-content: start; gap: 6px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: 14px; line-height: 1.5; transition: border-color .2s; }
+.nv-layer:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); }
+.nv-layer b { font-size: 16px; color: var(--text-primary, #e6edf7); }
+.nv-n { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 9px; font-weight: 800; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
+
+.nv-table { display: grid; margin-top: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; overflow: hidden; }
+.nv-tr { display: grid; grid-template-columns: 110px 130px repeat(3, minmax(0, 1fr)); gap: 12px; padding: 12px 16px; font-size: 14px; }
+.nv-tr + .nv-tr { border-top: 1px solid var(--border, #1f2d47); }
+.nv-th { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: var(--bg-card, #131c2e); }
+
+.nv-dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 0; }
+.nv-dl div { padding: 14px 16px; border-radius: 14px; background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); }
+.nv-dl dt { font-weight: 800; margin-bottom: 4px; }
+.nv-dl dd { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
+
+.nv-road { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 0; counter-reset: road; }
+.nv-road li { position: relative; display: grid; grid-template-columns: 76px minmax(0, 1fr); column-gap: 16px; padding: 0 0 18px 0; }
+.nv-road li::before { content: ""; position: absolute; left: 37px; top: 26px; bottom: 0; width: 2px; background: var(--border, #1f2d47); }
+.nv-road li:last-child::before { display: none; }
+.nv-state { grid-row: span 2; align-self: start; justify-self: center; padding: 3px 10px; border-radius: 99px; font-size: 12px; font-weight: 800; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); position: relative; }
+.nv-road li[data-state="now"] .nv-state { color: var(--on-accent, #fff); background: var(--accent, #3b82f6); border-color: transparent; }
+.nv-road li[data-state="next"] .nv-state { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
+.nv-road b { font-size: 16px; }
+.nv-road p { margin: 4px 0 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary, #96a7c2); max-width: 70ch; }
+
+.nv-code { margin-top: 12px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); overflow-x: auto; font-size: 13px; }
+.nv-check { margin: 12px 0 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px; }
+.nv-check li { position: relative; padding-left: 28px; font-size: 14.5px; line-height: 1.5; }
+.nv-check li::before { content: ""; position: absolute; left: 0; top: 2px; width: 18px; height: 18px; border-radius: 6px; border: 2px solid var(--accent, #3b82f6); }
+.nv-more { margin-top: 18px; font-size: 14px; }
+
+@media (max-width: 900px) {
+ .nv-frames { grid-template-columns: 120px minmax(0, 1fr); }
+ .nv-expanded { grid-column: 1 / -1; }
+ .nv-layers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (max-width: 640px) {
+ .nv-frames { grid-template-columns: 110px minmax(0, 1fr); gap: 12px; }
+ .nv-layers, .nv-dl, .nv-check { grid-template-columns: minmax(0, 1fr); }
+ .nv-tr { grid-template-columns: minmax(0, 1fr); gap: 4px; }
+ .nv-th { display: none; }
+ .nv-tr span[data-label]::before { content: attr(data-label) " · "; color: var(--text-muted, #7386a3); }
+ .nv-road li { grid-template-columns: 64px minmax(0, 1fr); column-gap: 12px; }
+ .nv-road li::before { left: 31px; }
+}
@@ -37,6 +37,7 @@ function publicPages(themes = []) {
@@ -37,6 +37,7 @@ function publicPages(themes = []) {
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
{ path: '/loading', changefreq: 'monthly', priority: 0.6, title: 'Loading: nothing spins', text: 'How OpenVibe waits: placeholders shaped like the content (kit.skeleton), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules.' },
{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },
{ path: '/forms', changefreq: 'monthly', priority: 0.6, title: 'Forms: OpenVibe’s own controls', text: 'OpenVibe’s select and form controls on every site: a menu on a computer, a sheet on a phone, choices that say whether they are ready, switches, fields and choice cards (openvibe-shared ov-forms).' },
+ { path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
@@ -88,6 +88,7 @@ function createPageRoutes(ctx) {
@@ -88,6 +88,7 @@ function createPageRoutes(ctx) {
});
});
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
// ── How OpenVibe moves: the mark's motion and the shared effects kit (plan L45) ──
r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
r.get('/motion', (req, res) => page(res, 200, motionView.motionPage({ base: layoutBase() }), req.viewer));
+ r.get('/native', (req, res) => page(res, 200, require('../views/native').nativePage(), req.viewer));
r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));
r.get('/loading', (req, res) => page(res, 200, require('../views/loading').loadingPage({ base: layoutBase() }), req.viewer));
// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──
// ── OpenVibe's own form controls (openvibe-shared ov-forms, 2026-11-03) ──
r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));
r.get('/forms', (req, res) => page(res, 200, require('../views/forms').formsPage({ base: layoutBase() }), req.viewer));
@@ -36,6 +36,7 @@ const NAV = [
@@ -36,6 +36,7 @@ const NAV = [
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
+ { label: 'Apps (Android, iOS)', href: '/native', icon: 'fa-display', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Developers', href: '/developers', icon: 'fa-code', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
{ label: 'Prompts', href: '/prompts', icon: 'fa-quote-left', more: true },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+'use strict';
+/**
+ * /native: from the web to Android and iOS, without a frame (the owner, 2026-11-07: "make sure OpenVibe.design
+ * supports Android and iOS apps … seamless"; 10-29: real apps, not web views). One page that ties the system
+ * together: one screen at three sizes, the four layers every platform shares (tokens, components, patterns, icons),
+ * the size classes, what each platform does its own way, the road from here, and a checklist for a page that is
+ * about to become an app screen.
+ *
+ * Server-rendered, no script: the three frames are markup and CSS (public/css/native.css).
+ *
+ * nativePage()
+ * LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST the content, so tests and the Markdown twin read the same words
+ */
+const { layout, esc } = require('../render/layout');
+
+const LAYERS = Object.freeze([
+ { name: 'Tokens', href: '/tokens', what: 'Colour, type, space, radius and motion as data (DTCG JSON). Android gets a Compose theme, iOS gets OvTokens and OvScale, the web gets CSS variables: one source, three files.' },
+ { name: 'Components', href: '/components', what: 'Each part described by what it is: its pieces, the tokens they wear, its states and how a screen reader hears it, with code for the web, Jetpack Compose and SwiftUI.' },
+ { name: 'Patterns', href: '/patterns', what: 'The rules production taught, each with the bug behind it, a live demo, and SwiftUI and Compose specs generated from the same tokens.' },
+ { name: 'Icons', href: '/icons', what: 'One glyph set for every platform: SVG and a font for the web, vector drawables for Android, PDF vectors for iOS, the same names everywhere.' },
+]);
+
+const CLASSES = Object.freeze([
+ { name: 'Compact', width: 'under 600 px', nav: 'A bottom tab bar, five places at most', add: 'A floating New over a sheet', detail: 'Full screen; Back returns to the list' },
+ { name: 'Medium', width: '600 to 839 px', nav: 'A navigation rail of icons', add: 'New at the top of the rail', detail: 'Side by side from 720 px' },
+ { name: 'Expanded', width: '840 px and wider', nav: 'A sidebar with words', add: 'New at the top of the sidebar', detail: 'The list and the open item together' },
+]);
+
+const PLATFORM = Object.freeze([
+ ['Back', 'Closes the top sheet or menu first, then leaves the screen. Android’s predictive back shows where it goes; on iOS a sheet swipes down and the edge swipe goes back; on the web, Back closes the menu before it leaves the page.'],
+ ['Safe areas', 'Nothing sits under the notch, the home indicator or a system bar: bars pad themselves by the insets, content scrolls under them.'],
+ ['Text size', 'Dynamic Type and Android font scale up to 200%. Rows wrap and grow; nothing clips or truncates a name to an ellipsis it did not need.'],
+ ['Motion', 'Durations from the motion tokens (180, 260, 360 ms); reduced motion removes entrances and keeps state changes as instant swaps.'],
+ ['Touch', 'Every target is 44 px (48 dp on Android). A press becomes a drag only after 6 px.'],
+ ['Haptics', 'A light tap when something is confirmed or sent (apps only); never for scrolling or ambient changes.'],
+ ['Your look', 'The theme you wear on openvibe.design reaches apps through the same tokens (GET /api/me/theme), so the app looks like your sites.'],
+ ['Notifications', 'Web push on the web; FCM on Android and APNs on iOS through OpenVibe.Network, with the same quiet hours and the same Do not disturb.'],
+]);
+
+const ROAD = Object.freeze([
+ { step: 'Phones first on the web', state: 'now', text: 'Every site at 390 px: no sideways scroll, 44 px targets, sheets instead of popovers, Back that closes them. Measured site by site.' },
+ { step: 'Install from the browser', state: 'next', text: 'A manifest and an offline shell for each site people use daily, so it opens like an app and the last screen shows without a network.' },
+ { step: 'Android', state: 'planned', text: 'A Compose shell. Native screens first where the web cannot reach: messages, the Live player, the camera, and Link’s phone automation (accessibility). Other screens render kit documents with native views. A direct APK first; Google Play when the account is opened.' },
+ { step: 'iOS', state: 'planned', text: 'A SwiftUI shell with the same screens and the same documents. The App Store when the account is opened.' },
+ { step: 'Desktop', state: 'planned', text: 'The OpenVibe app: Codes and Link in one native shell, with your machines, your agent and your files.' },
+]);
+
+const CHECKLIST = Object.freeze([
+ 'It is made of kit components, not one-off markup, so each part has a native twin.',
+ 'Every colour, size and duration is a token; no literal hex in a rule.',
+ 'At 390 px nothing scrolls sideways and nothing is hidden to make it fit.',
+ 'Adding is one button; its choices are in a sheet.',
+ 'Back closes what is open before it leaves.',
+ 'It reads well at 200% text.',
+ 'It works with reduced motion, and with JavaScript off on the web.',
+ 'Its data comes from a documented API, so an app can ask for the same thing.',
+]);
+
+/** One screen, drawn at three sizes: the same parts, a different arrangement. Markup only. */
+function frames() {
+ const rows = (n) => Array.from({ length: n }, (_, i) => `<span class="nv-row"><i></i><b style="--w:${60 + ((i * 17) % 30)}%"></b></span>`).join('');
+ const tiles = (n) => Array.from({ length: n }, () => '<span class="nv-tile"><i></i><b></b></span>').join('');
+ return `<div class="nv-frames" role="img" aria-label="The same files screen on a phone, a tablet and a computer: bottom tabs and a floating New on the phone, a rail on the tablet, a sidebar with the open item beside the list on the computer">
+<figure class="nv-frame nv-compact"><div class="nv-screen"><span class="nv-top"><b>My files</b></span><div class="nv-list">${rows(6)}</div><span class="nv-fab">+ New</span><span class="nv-tabs"><i></i><i class="on"></i><i></i><i></i></span></div><figcaption><b>Compact</b> a phone</figcaption></figure>
+<figure class="nv-frame nv-medium"><div class="nv-screen"><span class="nv-rail"><i class="nv-new"></i><i class="on"></i><i></i><i></i><i></i></span><div class="nv-main"><span class="nv-top"><b>My files</b></span><div class="nv-grid">${tiles(6)}</div></div></div><figcaption><b>Medium</b> a tablet or a fold</figcaption></figure>
+<figure class="nv-frame nv-expanded"><div class="nv-screen"><span class="nv-side"><span class="nv-new-w">+ New</span><span class="on">My files</span><span>Shared</span><span>Backups</span></span><div class="nv-main"><span class="nv-top"><b>My files</b></span><div class="nv-grid">${tiles(8)}</div></div><span class="nv-detail"><i></i><b></b><b></b><span class="nv-btn"></span></span></div><figcaption><b>Expanded</b> a computer</figcaption></figure>
+</div>`;
+}
+
+function nativePage() {
+ const body = `<header class="ds-page-h"><p class="ds-kicker">Apps</p><h1>From the web to Android and iOS, without a frame</h1>
+<p class="ds-lede">An OpenVibe app is never a web page in a frame. Every platform draws the same parts with its own views: the tokens, the components, the patterns and the icons on this site. The web stays the source of truth, and an app follows it screen by screen.</p></header>
+<section class="nv-sec" aria-labelledby="nv-one"><h2 id="nv-one">One screen, three sizes</h2>
+<p class="ds-lede">Not three designs: one set of parts, arranged by how much room there is.</p>
+${frames()}</section>
+<section class="nv-sec" aria-labelledby="nv-layers"><h2 id="nv-layers">Four layers every platform shares</h2>
+<div class="nv-layers">${LAYERS.map((l, i) => `<a class="nv-layer" href="${esc(l.href)}"><span class="nv-n">${i + 1}</span><b>${esc(l.name)}</b><span>${esc(l.what)}</span></a>`).join('')}</div></section>
+<section class="nv-sec" aria-labelledby="nv-classes"><h2 id="nv-classes">Size classes</h2>
+<p class="ds-lede">The platforms’ own window classes. The web’s bar folds at 760 px today; apps follow these.</p>
+<div class="nv-table" role="table" aria-label="Size classes">
+<div class="nv-tr nv-th" role="row"><span role="columnheader">Class</span><span role="columnheader">Width</span><span role="columnheader">Places</span><span role="columnheader">Adding</span><span role="columnheader">An open item</span></div>
+${CLASSES.map((c) => `<div class="nv-tr" role="row"><span role="cell"><b>${esc(c.name)}</b></span><span role="cell" data-label="Width">${esc(c.width)}</span><span role="cell" data-label="Places">${esc(c.nav)}</span><span role="cell" data-label="Adding">${esc(c.add)}</span><span role="cell" data-label="An open item">${esc(c.detail)}</span></div>`).join('')}
+</div></section>
+<section class="nv-sec" aria-labelledby="nv-platform"><h2 id="nv-platform">What each platform does its own way</h2>
+<dl class="nv-dl">${PLATFORM.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>
+<section class="nv-sec" aria-labelledby="nv-road"><h2 id="nv-road">The road from here</h2>
+<ol class="nv-road">${ROAD.map((r) => `<li data-state="${esc(r.state)}"><span class="nv-state">${esc(r.state === 'now' ? 'Now' : r.state === 'next' ? 'Next' : 'Planned')}</span><b>${esc(r.step)}</b><p>${esc(r.text)}</p></li>`).join('')}</ol></section>
+<section class="nv-sec" aria-labelledby="nv-screens"><h2 id="nv-screens">A screen as data</h2>
+<p class="ds-lede">A screen can travel as a tree of kit components. The web draws it with the kit; an app draws the same tree with Compose or SwiftUI, so a new screen reaches every platform without a release.</p>
+<pre class="nv-code"><code>{ "type": "rail", "title": "Trending",
+ "children": [
+ { "type": "card", "title": "A rainy night walk", "meta": "23 views this week", "href": "https://openvibe.video/v/6119" }
+ ] }</code></pre></section>
+<section class="nv-sec" aria-labelledby="nv-check"><h2 id="nv-check">Ready to become an app screen?</h2>
+<ul class="nv-check">${CHECKLIST.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>
+<p class="nv-more"><a href="/components?p=android">Components for Android</a> · <a href="/components?p=ios">Components for iOS</a> · <a href="/tokens">Tokens</a> · <a href="/patterns">Patterns</a></p></section>`;
+ return layout({
+ title: 'Apps for Android and iOS: the OpenVibe design system, natively',
+ description: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.',
+ path: '/native',
+ active: '/native',
+ styles: ['css/native.css'],
+ body: `<div class="ds-wrap nv">${body}</div>`,
+ });
+}
+
+module.exports = { nativePage, LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// /native (11-07): the road from the web to Android and iOS, without a frame. The page renders whole without
+// JavaScript, links each shared layer, says every size class, every platform difference and every road step, is in
+// the sitemap, and is reachable from the bar's More.
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { LAYERS, CLASSES, PLATFORM, ROAD, CHECKLIST } = require('../server/views/native');
+const { NAV } = require('../server/render/layout');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the page says the whole road, with every layer linked', async () => {
+ const r = await t.get('/native');
+ assert.equal(r.status, 200);
+ assert.match(r.text, /without a frame/);
+ for (const l of LAYERS) assert.ok(r.text.includes(`href="${l.href}"`), `${l.name} is linked`);
+ for (const c of CLASSES) assert.ok(r.text.includes(c.name) && r.text.includes(c.width), c.name);
+ for (const [k] of PLATFORM) assert.ok(r.text.includes(`<dt>${k}</dt>`), k);
+ for (const s of ROAD) assert.ok(r.text.includes(s.step), s.step);
+ assert.equal((r.text.match(/<li>/g) || []).length >= CHECKLIST.length, true, 'the checklist');
+ assert.ok(r.text.includes('/css/native.css'), 'its stylesheet');
+ assert.ok(!/<script[^>]*native/.test(r.text), 'no page script: whole without JavaScript');
+ });
+ await check('it is in the sitemap and under More', async () => {
+ const map = await t.get('/sitemap.xml');
+ assert.match(map.text, /\/native</);
+ assert.ok(NAV.some((l) => l.href === '/native' && l.more), 'More lists it');
+ const css = await t.get('/css/native.css');
+ assert.equal(css.status, 200);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });