openvibers/openvibe.design Commits

/native: from the web to Android and iOS, without a frame (#174)

OP OpenVibers committed 6 days ago · bd642eab04 · 6 files, +233 −0

Changes

ddiff --git a/public/css/native.css b/public/css/native.css
nnew file mode 100644
iindex 0000000..34b8b74
---- /dev/null
++++ b/public/css/native.css
@@@ -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; }
++}
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex b46d78f..834df9e 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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.' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3db8be8..1653449 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 3587888..6c57c47 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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 },
];
ddiff --git a/server/views/native.js b/server/views/native.js
nnew file mode 100644
iindex 0000000..6d71e6d
---- /dev/null
++++ b/server/views/native.js
@@@ -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 };
ddiff --git a/test/native-page.test.js b/test/native-page.test.js
nnew file mode 100644
iindex 0000000..6e4e2b3
---- /dev/null
++++ b/test/native-page.test.js
@@@ -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); });