diff --git a/docs/agent-guide.md b/docs/agent-guide.md index c232cbf..d335323 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -29,8 +29,16 @@ Concretely: as "the link". If the control plane advertises no `home_url`, say so instead of inventing one, and say what `miakapp status` proved. - **Ask once, at the start, and only for what you cannot find.** Access comes - from pairing (§9), the existing installation from inventory (§3). Repeated - permission questions in the middle of the work are a failure of the work. + from pairing (§9), the existing installation from inventory (§3), and the + right to wire physical controls from one question at the start of the + step-by-step journey (below). Repeated permission questions in the middle of + the work are a failure of the work. +- **Get it right the first time.** The owner should never have to ask you to + remove something you added or to fix how it looks or feels. Before you + publish, read `miakapp docs design` and follow it to the letter, check the + app on a phone (§5, "Check it on a phone"), and remove anything the guides + tell you not to add (a connection banner, your own bottom bar, a value + nobody asked for). - **You need nothing from the platform's source.** This guide and the published CLI — including the app starter `miakapp init --starter app` writes — are the whole toolchain for the interface. Never edit or redeploy the Miakapp @@ -54,7 +62,14 @@ A first session usually goes like this: find a way to control them from Miakapp."* 2. The owner lists devices ("I have Hue lamps…"). Ask only for what you need to reach them: the credentials of the brand's account, or whether there is a - Raspberry Pi or a small PC that can run 24/7 in the house. + Raspberry Pi or a small PC that can run 24/7 in the house. In the same + message, ask **once** for the green light on physical controls: *"As we go, + I'll add the buttons that act on the house (lights, shutters, gate, + heating…), each one reserved to the people you choose. OK for you?"* That + answer covers every control of the journey: do not ask again device by + device. You still never trigger something that opens, unlocks or heats on + the real hardware yourself: when such a control is ready, tell the owner it + is ready to try. 3. Connect them, publish, check, then: *"I've just added your lamps! Do you want me to group them by room? Otherwise we can start adding people to the home: family members, guests or friends?"* @@ -71,10 +86,11 @@ Rules for every checkpoint: Two scopes must not be confused: -| In scope by default | Needs the owner's explicit, separate request | +| In scope by default | Needs the owner's explicit request | | --- | --- | -| reading the existing installation (§3) | changing flows, automations, brokers or devices that already run the house | -| writing and testing a coordinator and an app | granting a physical action (heat, lock, unlock, open, close) that the owner has not named | +| reading the existing installation (§3) | changing flows, automations or devices that already run the house | +| giving the coordinator its own access to devices (its own broker user, token, GPIO) | removing or replacing the existing hub (the switch-over) | +| writing and testing a coordinator and an app | physical controls, until the owner answered the one question above | | publishing, verifying and rolling back the V4 interface | anything on the owner's own machines beyond the coordinator you were asked to run | Caution about the second column is not a reason to withhold the first. A new V4 @@ -183,6 +199,26 @@ Write down what you found before you write the configuration. The state paths yo choose become a disclosure boundary and a public interface at the same time, and renaming one after the household has used it is not free. +### Talk to the devices, not to the old hub + +Read the existing hub to learn the house, never to depend on it. The +coordinator talks **directly** to the devices: MQTT and Zigbee2MQTT topics, the +Pi's GPIO for relays, the local HTTP or UDP API of a device, an RF or IR +transmitter. Owners move to Miakapp to get rid of the old stack (Node-RED, Home +Assistant, a vendor bridge), and a home that still needs it is not finished. + +- Give the coordinator its own access: its own MQTT user on the broker, its own + token. Do not reuse the hub's credentials, and do not call the hub's API + (Node-RED HTTP endpoints, its context files, a Home Assistant REST call) for + something the device itself can tell you or do. +- When a direct path is not ready yet, the hub may serve as a **temporary + bridge** for that one datum or action: mark it `bridge: ` in + `docs/inventory.md`, keep it behind one function you can swap, and tell the + owner what is left before the hub can be switched off. +- Two masters on the same relay or device disagree about its state. While the + old hub still drives something, keep your direct driver ready but off, and + switch over in one step with the owner. + ### The inventory comes before the design Commit the inventory as `docs/inventory.md` in the home repository, one line per @@ -195,6 +231,7 @@ datum or action, before you design a single screen: | how fresh | reports every ~60 s; last seen 2 min ago | | who may read it | every member / owner only | | command | none — a measurement, not a control | +| path | direct (MQTT) / bridge: Node-RED until the switch-over | Then design **from** it: the V1 shows every datum the household would care about and every action that is both technically possible and authorized, @@ -321,10 +358,11 @@ house, you have taken a name another integration may need; scope what you own. ### A whole-house application (default) `miakapp init --starter app` writes an `app:` section and a buildable app to -edit. You own the document: build any DOM, ship your own CSS, route with -`location.hash`, use React, Svelte, Vue, charts or WebAssembly — anything a -bundler can inline. The deliverable is **one classic-script IIFE** of at most -2 MiB, with no `import()` and no source map; the starter's `bun run build` is: +edit. You own the document: build any DOM, ship your own CSS, use React, +Svelte, Vue, charts or WebAssembly — anything a bundler can inline. Navigate +with the starter's `createNav()`, which gives every page a history entry and a +swipe back. The deliverable is **one classic-script IIFE** of at most 2 MiB, +with no `import()` and no source map; the starter's `bun run build` is: ```bash bun build app/main.ts --format=iife --minify --outfile dist/app.js @@ -369,6 +407,11 @@ and read everything from state, which the coordinator filters per resident. **Staleness.** `home.state.stale` means the snapshot may no longer reflect the house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat reading that is silently forty minutes old is worse than one labelled uncertain. +Showing it is the Miakapp bar's job and yours together: the bar says +"Connecting…" or "Reconnecting…", and your app **dims** the values that may be +old (`.is-stale` on `.mk-app`) and disables the controls. Never add a banner, +toast or strip of your own for it ("The home is not answering"): it repeats the +bar, and every resident sees it each time the app opens. **Staging.** `home.staging` is true while a release is staged: rendering works, calls and events do not. Disable the controls and say why. @@ -395,6 +438,86 @@ world does not have a rollback. Surface it, let the next state snapshot settle it, and let the person decide. The same principle has a CLI counterpart: exit code 7. +### Check it on a phone + +Residents use the app on a phone, installed on the home screen. Look at every +page at phone size, with touch, before you tell the owner it is done: the home +screen fits without scrolling, nothing overflows, held actions fill and fire, +and a swipe from the left edge goes back. + +Use the browser you already have; **never install one** (`playwright +install`, Chromium, a headless shell): on Molted and on most agent machines it +is forbidden, and it is never needed. `playwright-core` is the client alone and +downloads no browser; on a Molted machine it drives the cloud browser through +`$AGENT_BROWSER_BASIC_CDP`. + +**Do not trust viewport emulation.** A remote or shared browser often ignores +it: on Molted's cloud browser, `newContext({ viewport, isMobile })`, +`Emulation.setDeviceMetricsOverride` and `agent-browser set viewport` all leave +the page at desktop width. Get the phone size from a frame instead, and measure +`innerWidth` inside the app before you trust a screenshot. + +1. Build, then serve a local preview next to `dist/`: `phone.html` holds the + app in a 390 × 844 frame, `frame.html` stands in for Miakapp's bridge with + the real values you read from the house. + + ```html + + + + + + + + ``` + + Serve it where the browser can reach it (on Molted: a port exposed with a + public URL). A preview call never reaches the house. + +2. Drive it with touch. Taps work with `page.touchscreen.tap`; a swipe is a + sequence of CDP touch events: + + ```js + import { chromium } from 'playwright-core'; // npm i playwright-core, in a scratch directory + + const browser = await chromium.connectOverCDP(process.env.AGENT_BROWSER_BASIC_CDP); + const context = await browser.newContext({ hasTouch: true }); + const page = await context.newPage(); + await page.goto(PREVIEW_URL + '/phone.html', { waitUntil: 'commit' }); + const app = page.frameLocator('iframe'); + await app.locator('.mk-entry').first().waitFor(); + console.log(await page.frames()[1].evaluate(() => innerWidth)); // must print 390 + await page.screenshot({ path: 'home.png', clip: { x: 0, y: 0, width: 390, height: 844 } }); + + const cdp = await context.newCDPSession(page); + const touch = (type, x, y) => cdp.send('Input.dispatchTouchEvent', + { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] }); + await touch('touchStart', 6, 400); // the left edge + for (let x = 46; x <= 286; x += 40) await touch('touchMove', x, 400); + await touch('touchEnd', 286, 400); // should go back one page + + await page.close(); // your page only, never browser.close() + ``` + + Remote control adds latency between touch events, so a "flick" sent this + way arrives slowly; test speed with a long swipe, not a flick. + +3. Then open the published `home_url` if your browser is signed in as a + member, and say plainly what you could not check. A desktop-width + screenshot is never a phone check. + ## 6. The intersection rule Every name an app may touch appears in **two** places, and the effective @@ -627,7 +750,15 @@ prevent. line, and a test proves an unauthorized caller is refused. - [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test asserts the correspondence. -- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] `home.state.stale` dims the values and disables the controls, with no + banner of your own; `home.staging` is visible. +- [ ] You looked at every page at 390 × 844 with a touch pointer (§5); pages + slide, the system back and the left-edge swipe go back, held actions fire + after 0.3 s. +- [ ] States use the four colours of `miakapp docs design` (green safe, + orange to look at, gold on, red alarm only), always with a word. +- [ ] The coordinator talks to the devices directly; any hub left is a + `bridge:` line in the inventory with what remains to switch it off. - [ ] No call path retries an unknown outcome. - [ ] State is written before its event is published. - [ ] `bun run check` is green, and CI ran it — not just you. diff --git a/packages/cli/assets/agent-guide.md b/packages/cli/assets/agent-guide.md index c232cbf..d335323 100644 --- a/packages/cli/assets/agent-guide.md +++ b/packages/cli/assets/agent-guide.md @@ -29,8 +29,16 @@ Concretely: as "the link". If the control plane advertises no `home_url`, say so instead of inventing one, and say what `miakapp status` proved. - **Ask once, at the start, and only for what you cannot find.** Access comes - from pairing (§9), the existing installation from inventory (§3). Repeated - permission questions in the middle of the work are a failure of the work. + from pairing (§9), the existing installation from inventory (§3), and the + right to wire physical controls from one question at the start of the + step-by-step journey (below). Repeated permission questions in the middle of + the work are a failure of the work. +- **Get it right the first time.** The owner should never have to ask you to + remove something you added or to fix how it looks or feels. Before you + publish, read `miakapp docs design` and follow it to the letter, check the + app on a phone (§5, "Check it on a phone"), and remove anything the guides + tell you not to add (a connection banner, your own bottom bar, a value + nobody asked for). - **You need nothing from the platform's source.** This guide and the published CLI — including the app starter `miakapp init --starter app` writes — are the whole toolchain for the interface. Never edit or redeploy the Miakapp @@ -54,7 +62,14 @@ A first session usually goes like this: find a way to control them from Miakapp."* 2. The owner lists devices ("I have Hue lamps…"). Ask only for what you need to reach them: the credentials of the brand's account, or whether there is a - Raspberry Pi or a small PC that can run 24/7 in the house. + Raspberry Pi or a small PC that can run 24/7 in the house. In the same + message, ask **once** for the green light on physical controls: *"As we go, + I'll add the buttons that act on the house (lights, shutters, gate, + heating…), each one reserved to the people you choose. OK for you?"* That + answer covers every control of the journey: do not ask again device by + device. You still never trigger something that opens, unlocks or heats on + the real hardware yourself: when such a control is ready, tell the owner it + is ready to try. 3. Connect them, publish, check, then: *"I've just added your lamps! Do you want me to group them by room? Otherwise we can start adding people to the home: family members, guests or friends?"* @@ -71,10 +86,11 @@ Rules for every checkpoint: Two scopes must not be confused: -| In scope by default | Needs the owner's explicit, separate request | +| In scope by default | Needs the owner's explicit request | | --- | --- | -| reading the existing installation (§3) | changing flows, automations, brokers or devices that already run the house | -| writing and testing a coordinator and an app | granting a physical action (heat, lock, unlock, open, close) that the owner has not named | +| reading the existing installation (§3) | changing flows, automations or devices that already run the house | +| giving the coordinator its own access to devices (its own broker user, token, GPIO) | removing or replacing the existing hub (the switch-over) | +| writing and testing a coordinator and an app | physical controls, until the owner answered the one question above | | publishing, verifying and rolling back the V4 interface | anything on the owner's own machines beyond the coordinator you were asked to run | Caution about the second column is not a reason to withhold the first. A new V4 @@ -183,6 +199,26 @@ Write down what you found before you write the configuration. The state paths yo choose become a disclosure boundary and a public interface at the same time, and renaming one after the household has used it is not free. +### Talk to the devices, not to the old hub + +Read the existing hub to learn the house, never to depend on it. The +coordinator talks **directly** to the devices: MQTT and Zigbee2MQTT topics, the +Pi's GPIO for relays, the local HTTP or UDP API of a device, an RF or IR +transmitter. Owners move to Miakapp to get rid of the old stack (Node-RED, Home +Assistant, a vendor bridge), and a home that still needs it is not finished. + +- Give the coordinator its own access: its own MQTT user on the broker, its own + token. Do not reuse the hub's credentials, and do not call the hub's API + (Node-RED HTTP endpoints, its context files, a Home Assistant REST call) for + something the device itself can tell you or do. +- When a direct path is not ready yet, the hub may serve as a **temporary + bridge** for that one datum or action: mark it `bridge: ` in + `docs/inventory.md`, keep it behind one function you can swap, and tell the + owner what is left before the hub can be switched off. +- Two masters on the same relay or device disagree about its state. While the + old hub still drives something, keep your direct driver ready but off, and + switch over in one step with the owner. + ### The inventory comes before the design Commit the inventory as `docs/inventory.md` in the home repository, one line per @@ -195,6 +231,7 @@ datum or action, before you design a single screen: | how fresh | reports every ~60 s; last seen 2 min ago | | who may read it | every member / owner only | | command | none — a measurement, not a control | +| path | direct (MQTT) / bridge: Node-RED until the switch-over | Then design **from** it: the V1 shows every datum the household would care about and every action that is both technically possible and authorized, @@ -321,10 +358,11 @@ house, you have taken a name another integration may need; scope what you own. ### A whole-house application (default) `miakapp init --starter app` writes an `app:` section and a buildable app to -edit. You own the document: build any DOM, ship your own CSS, route with -`location.hash`, use React, Svelte, Vue, charts or WebAssembly — anything a -bundler can inline. The deliverable is **one classic-script IIFE** of at most -2 MiB, with no `import()` and no source map; the starter's `bun run build` is: +edit. You own the document: build any DOM, ship your own CSS, use React, +Svelte, Vue, charts or WebAssembly — anything a bundler can inline. Navigate +with the starter's `createNav()`, which gives every page a history entry and a +swipe back. The deliverable is **one classic-script IIFE** of at most 2 MiB, +with no `import()` and no source map; the starter's `bun run build` is: ```bash bun build app/main.ts --format=iife --minify --outfile dist/app.js @@ -369,6 +407,11 @@ and read everything from state, which the coordinator filters per resident. **Staleness.** `home.state.stale` means the snapshot may no longer reflect the house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat reading that is silently forty minutes old is worse than one labelled uncertain. +Showing it is the Miakapp bar's job and yours together: the bar says +"Connecting…" or "Reconnecting…", and your app **dims** the values that may be +old (`.is-stale` on `.mk-app`) and disables the controls. Never add a banner, +toast or strip of your own for it ("The home is not answering"): it repeats the +bar, and every resident sees it each time the app opens. **Staging.** `home.staging` is true while a release is staged: rendering works, calls and events do not. Disable the controls and say why. @@ -395,6 +438,86 @@ world does not have a rollback. Surface it, let the next state snapshot settle it, and let the person decide. The same principle has a CLI counterpart: exit code 7. +### Check it on a phone + +Residents use the app on a phone, installed on the home screen. Look at every +page at phone size, with touch, before you tell the owner it is done: the home +screen fits without scrolling, nothing overflows, held actions fill and fire, +and a swipe from the left edge goes back. + +Use the browser you already have; **never install one** (`playwright +install`, Chromium, a headless shell): on Molted and on most agent machines it +is forbidden, and it is never needed. `playwright-core` is the client alone and +downloads no browser; on a Molted machine it drives the cloud browser through +`$AGENT_BROWSER_BASIC_CDP`. + +**Do not trust viewport emulation.** A remote or shared browser often ignores +it: on Molted's cloud browser, `newContext({ viewport, isMobile })`, +`Emulation.setDeviceMetricsOverride` and `agent-browser set viewport` all leave +the page at desktop width. Get the phone size from a frame instead, and measure +`innerWidth` inside the app before you trust a screenshot. + +1. Build, then serve a local preview next to `dist/`: `phone.html` holds the + app in a 390 × 844 frame, `frame.html` stands in for Miakapp's bridge with + the real values you read from the house. + + ```html + + + + + + + + ``` + + Serve it where the browser can reach it (on Molted: a port exposed with a + public URL). A preview call never reaches the house. + +2. Drive it with touch. Taps work with `page.touchscreen.tap`; a swipe is a + sequence of CDP touch events: + + ```js + import { chromium } from 'playwright-core'; // npm i playwright-core, in a scratch directory + + const browser = await chromium.connectOverCDP(process.env.AGENT_BROWSER_BASIC_CDP); + const context = await browser.newContext({ hasTouch: true }); + const page = await context.newPage(); + await page.goto(PREVIEW_URL + '/phone.html', { waitUntil: 'commit' }); + const app = page.frameLocator('iframe'); + await app.locator('.mk-entry').first().waitFor(); + console.log(await page.frames()[1].evaluate(() => innerWidth)); // must print 390 + await page.screenshot({ path: 'home.png', clip: { x: 0, y: 0, width: 390, height: 844 } }); + + const cdp = await context.newCDPSession(page); + const touch = (type, x, y) => cdp.send('Input.dispatchTouchEvent', + { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y }] }); + await touch('touchStart', 6, 400); // the left edge + for (let x = 46; x <= 286; x += 40) await touch('touchMove', x, 400); + await touch('touchEnd', 286, 400); // should go back one page + + await page.close(); // your page only, never browser.close() + ``` + + Remote control adds latency between touch events, so a "flick" sent this + way arrives slowly; test speed with a long swipe, not a flick. + +3. Then open the published `home_url` if your browser is signed in as a + member, and say plainly what you could not check. A desktop-width + screenshot is never a phone check. + ## 6. The intersection rule Every name an app may touch appears in **two** places, and the effective @@ -627,7 +750,15 @@ prevent. line, and a test proves an unauthorized caller is refused. - [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test asserts the correspondence. -- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] `home.state.stale` dims the values and disables the controls, with no + banner of your own; `home.staging` is visible. +- [ ] You looked at every page at 390 × 844 with a touch pointer (§5); pages + slide, the system back and the left-edge swipe go back, held actions fire + after 0.3 s. +- [ ] States use the four colours of `miakapp docs design` (green safe, + orange to look at, gold on, red alarm only), always with a word. +- [ ] The coordinator talks to the devices directly; any hub left is a + `bridge:` line in the inventory with what remains to switch it off. - [ ] No call path retries an unknown outcome. - [ ] State is written before its event is published. - [ ] `bun run check` is green, and CI ran it — not just you. diff --git a/packages/cli/assets/design/components.css b/packages/cli/assets/design/components.css index 7a811f6..e4866ed 100644 --- a/packages/cli/assets/design/components.css +++ b/packages/cli/assets/design/components.css @@ -13,7 +13,21 @@ margin: 0; -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; + /* An app, not a page: no text selection or callout on a long press, except where residents type */ + -webkit-user-select: none; user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } +.mk-app :is(input, textarea, select, [contenteditable]) { -webkit-user-select: text; user-select: text; } + +/* Pages (createNav): one layer per page, each scrolls on its own; the top one follows the finger on a swipe back */ +.mk-pages { position: fixed; inset: 0; overflow: hidden; isolation: isolate; } +.mk-page { + position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; + touch-action: pan-y; background: var(--mk-bg-art), var(--mk-bg); +} +.mk-page.is-moving { z-index: 2; box-shadow: -18px 0 40px -12px rgb(0 0 0 / .45); will-change: transform; } +.mk-pages-shade { position: absolute; inset: 0; z-index: 1; background: #000; opacity: 0; pointer-events: none; } /* Glass: the one surface. Stack at most two levels. */ .mk-glass { @@ -73,7 +87,7 @@ .mk-eyebrow { font-size: var(--mk-text-xs); font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: var(--mk-ink-3); } .mk-headline { margin: 0; font-size: var(--mk-display); font-weight: 800; letter-spacing: -.045em; line-height: 1.02; } .mk-headline em { font-style: normal; background: var(--mk-accent-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; } -.mk-headline em.is-alert { background: linear-gradient(110deg, color-mix(in srgb, var(--mk-warn) 70%, #fff), var(--mk-warn) 55%, var(--mk-heat)); -webkit-background-clip: text; background-clip: text; } +.mk-headline em.is-warn, .mk-headline em.is-alert { background: linear-gradient(110deg, color-mix(in srgb, var(--mk-warn) 70%, #fff), var(--mk-warn) 55%, var(--mk-heat)); -webkit-background-clip: text; background-clip: text; } /* Entry tile: the home screen opens pages. Tiles of different sizes fill the screen */ .mk-entries { flex: 1; display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(108px, 1fr); gap: var(--mk-3); } @@ -164,7 +178,8 @@ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; } .mk-orb .mk-i { width: 112px; height: 112px; stroke-width: 1.4; filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tint) 55%, transparent)); } -.mk-orb.is-open, .mk-orb.is-alert { --tint: var(--mk-warn); } +.mk-orb.is-open, .mk-orb.is-warn { --tint: var(--mk-warn); } +.mk-orb.is-alert { --tint: var(--mk-alert); } /* A hue for a place or a kind of device, on a tile or an orb */ .mk-tint-lilac { --tint: var(--mk-lilac); --glow: 34%; } @@ -179,7 +194,11 @@ .mk-state .now { font-size: 56px; font-weight: 800; letter-spacing: -.05em; line-height: 1; } .mk-state .since { display: inline-flex; justify-content: center; align-items: center; gap: var(--mk-2); font-size: var(--mk-text-sm); font-weight: 600; color: var(--mk-ink-2); } .mk-state .since.is-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--mk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mk-ok) 22%, transparent); } -.mk-state.is-open .now, .mk-state.is-alert .now { color: var(--mk-warn); } +/* The state word carries its meaning (see .mk-status): green and calm when safe, orange to look at, gold when on, red only for an alarm or a failure */ +.mk-state.is-ok .now { color: color-mix(in srgb, var(--mk-ok) 80%, var(--mk-ink)); } +.mk-state.is-open .now, .mk-state.is-warn .now { color: var(--mk-warn); } +.mk-state.is-on .now { color: var(--mk-honey); } +.mk-state.is-alert .now { color: var(--mk-alert); } /* Actions: under the thumb, at the bottom. Risky direction first and held; safe direction alone below */ .mk-actions { margin-top: auto; display: grid; grid-template-columns: 1fr 1fr; gap: var(--mk-3); } @@ -283,13 +302,34 @@ .mk-level::-webkit-slider-thumb { appearance: none; width: 4px; height: 44px; background: transparent; } .mk-level::-moz-range-thumb { width: 4px; height: 44px; border: 0; background: transparent; } -/* The home is not answering: say it, never hide it */ -.mk-stale-banner { - display: flex; align-items: center; gap: var(--mk-2); - padding: 10px 14px; border-radius: var(--mk-pill); - background: color-mix(in srgb, var(--mk-warn) 18%, var(--mk-surface-strong)); - color: var(--mk-ink); font-size: var(--mk-text-sm); font-weight: 600; +/* + * Status: a word and a small dot, never a big coloured block. Always a word, so + * colour is never the only signal (colour blindness). + * is-ok normal and safe: closed, locked, armed (a discreet green) + * is-warn to look at: open, moving, low battery (orange) + * is-on on or running: a light, heating (gold) + * is-alert an alarm or a failure, nothing else (red) + */ +.mk-status { display: inline-flex; align-items: center; gap: .5em; font-weight: 700; } +.mk-status::before { + content: ""; flex: none; width: .5em; height: .5em; border-radius: 50%; background: currentColor; + box-shadow: 0 0 0 .22em color-mix(in srgb, currentColor 22%, transparent); +} +.mk-status.is-ok { color: color-mix(in srgb, var(--mk-ok) 85%, var(--mk-ink)); } +.mk-status.is-warn { color: var(--mk-warn); } +.mk-status.is-on { color: var(--mk-honey); } +.mk-status.is-alert { color: var(--mk-alert); } +.mk-entry .mk-status, .mk-state .mk-status { display: flex; padding-left: .25em; } + +/* + * Old values. The Miakapp bar already says when the home is (re)connecting: + * never add a banner of your own. Put .is-stale on .mk-app while state.stale is + * true and the values dim; disable the controls. .mk-stale dims one value + * (a sensor silent for a while). + */ +.mk-app.is-stale :is(.now, .big, .hint, .value, .mk-status, .mk-orb), .mk-stale { + opacity: .45; filter: saturate(.2); + transition: opacity var(--mk-slow) var(--mk-ease), filter var(--mk-slow) var(--mk-ease); } -.mk-stale-banner::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--mk-warn); } :focus-visible { outline: 3px solid var(--mk-accent); outline-offset: 2px; } diff --git a/packages/cli/assets/design/gallery.html b/packages/cli/assets/design/gallery.html index 32a91ef..c14f4a3 100644 --- a/packages/cli/assets/design/gallery.html +++ b/packages/cli/assets/design/gallery.html @@ -141,8 +141,8 @@ --mk-button-ink: #0a1024; --mk-heat: #ff9a5c; --mk-cool: #7ea2ff; - --mk-ok: #4fd39a; - --mk-warn: #ffbf47; + --mk-ok: #5fd6a0; + --mk-warn: #ff9f43; /* orange, kept apart from the gold of what is on */ --mk-alert: #ff6b6f; --mk-lilac: #b9a6ff; --mk-sky: #8fb4ff; @@ -239,7 +239,21 @@ margin: 0; -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; + /* An app, not a page: no text selection or callout on a long press, except where residents type */ + -webkit-user-select: none; user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; } +.mk-app :is(input, textarea, select, [contenteditable]) { -webkit-user-select: text; user-select: text; } + +/* Pages (createNav): one layer per page, each scrolls on its own; the top one follows the finger on a swipe back */ +.mk-pages { position: fixed; inset: 0; overflow: hidden; isolation: isolate; } +.mk-page { + position: absolute; inset: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; + touch-action: pan-y; background: var(--mk-bg-art), var(--mk-bg); +} +.mk-page.is-moving { z-index: 2; box-shadow: -18px 0 40px -12px rgb(0 0 0 / .45); will-change: transform; } +.mk-pages-shade { position: absolute; inset: 0; z-index: 1; background: #000; opacity: 0; pointer-events: none; } /* Glass: the one surface. Stack at most two levels. */ .mk-glass { @@ -299,7 +313,7 @@ .mk-eyebrow { font-size: var(--mk-text-xs); font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: var(--mk-ink-3); } .mk-headline { margin: 0; font-size: var(--mk-display); font-weight: 800; letter-spacing: -.045em; line-height: 1.02; } .mk-headline em { font-style: normal; background: var(--mk-accent-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; } -.mk-headline em.is-alert { background: linear-gradient(110deg, color-mix(in srgb, var(--mk-warn) 70%, #fff), var(--mk-warn) 55%, var(--mk-heat)); -webkit-background-clip: text; background-clip: text; } +.mk-headline em.is-warn, .mk-headline em.is-alert { background: linear-gradient(110deg, color-mix(in srgb, var(--mk-warn) 70%, #fff), var(--mk-warn) 55%, var(--mk-heat)); -webkit-background-clip: text; background-clip: text; } /* Entry tile: the home screen opens pages. Tiles of different sizes fill the screen */ .mk-entries { flex: 1; display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: minmax(108px, 1fr); gap: var(--mk-3); } @@ -390,7 +404,8 @@ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; } .mk-orb .mk-i { width: 112px; height: 112px; stroke-width: 1.4; filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tint) 55%, transparent)); } -.mk-orb.is-open, .mk-orb.is-alert { --tint: var(--mk-warn); } +.mk-orb.is-open, .mk-orb.is-warn { --tint: var(--mk-warn); } +.mk-orb.is-alert { --tint: var(--mk-alert); } /* A hue for a place or a kind of device, on a tile or an orb */ .mk-tint-lilac { --tint: var(--mk-lilac); --glow: 34%; } @@ -405,7 +420,11 @@ .mk-state .now { font-size: 56px; font-weight: 800; letter-spacing: -.05em; line-height: 1; } .mk-state .since { display: inline-flex; justify-content: center; align-items: center; gap: var(--mk-2); font-size: var(--mk-text-sm); font-weight: 600; color: var(--mk-ink-2); } .mk-state .since.is-live::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--mk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--mk-ok) 22%, transparent); } -.mk-state.is-open .now, .mk-state.is-alert .now { color: var(--mk-warn); } +/* The state word carries its meaning (see .mk-status): green and calm when safe, orange to look at, gold when on, red only for an alarm or a failure */ +.mk-state.is-ok .now { color: color-mix(in srgb, var(--mk-ok) 80%, var(--mk-ink)); } +.mk-state.is-open .now, .mk-state.is-warn .now { color: var(--mk-warn); } +.mk-state.is-on .now { color: var(--mk-honey); } +.mk-state.is-alert .now { color: var(--mk-alert); } /* Actions: under the thumb, at the bottom. Risky direction first and held; safe direction alone below */ .mk-actions { margin-top: auto; display: grid; grid-template-columns: 1fr 1fr; gap: var(--mk-3); } @@ -509,14 +528,35 @@ .mk-level::-webkit-slider-thumb { appearance: none; width: 4px; height: 44px; background: transparent; } .mk-level::-moz-range-thumb { width: 4px; height: 44px; border: 0; background: transparent; } -/* The home is not answering: say it, never hide it */ -.mk-stale-banner { - display: flex; align-items: center; gap: var(--mk-2); - padding: 10px 14px; border-radius: var(--mk-pill); - background: color-mix(in srgb, var(--mk-warn) 18%, var(--mk-surface-strong)); - color: var(--mk-ink); font-size: var(--mk-text-sm); font-weight: 600; +/* + * Status: a word and a small dot, never a big coloured block. Always a word, so + * colour is never the only signal (colour blindness). + * is-ok normal and safe: closed, locked, armed (a discreet green) + * is-warn to look at: open, moving, low battery (orange) + * is-on on or running: a light, heating (gold) + * is-alert an alarm or a failure, nothing else (red) + */ +.mk-status { display: inline-flex; align-items: center; gap: .5em; font-weight: 700; } +.mk-status::before { + content: ""; flex: none; width: .5em; height: .5em; border-radius: 50%; background: currentColor; + box-shadow: 0 0 0 .22em color-mix(in srgb, currentColor 22%, transparent); +} +.mk-status.is-ok { color: color-mix(in srgb, var(--mk-ok) 85%, var(--mk-ink)); } +.mk-status.is-warn { color: var(--mk-warn); } +.mk-status.is-on { color: var(--mk-honey); } +.mk-status.is-alert { color: var(--mk-alert); } +.mk-entry .mk-status, .mk-state .mk-status { display: flex; padding-left: .25em; } + +/* + * Old values. The Miakapp bar already says when the home is (re)connecting: + * never add a banner of your own. Put .is-stale on .mk-app while state.stale is + * true and the values dim; disable the controls. .mk-stale dims one value + * (a sensor silent for a while). + */ +.mk-app.is-stale :is(.now, .big, .hint, .value, .mk-status, .mk-orb), .mk-stale { + opacity: .45; filter: saturate(.2); + transition: opacity var(--mk-slow) var(--mk-ease), filter var(--mk-slow) var(--mk-ease); } -.mk-stale-banner::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--mk-warn); } :focus-visible { outline: 3px solid var(--mk-accent); outline-offset: 2px; } @@ -549,6 +589,10 @@ .screen .status { position: absolute; top: 0; left: 0; right: 0; height: 40px; display: flex; justify-content: space-between; align-items: center; padding: 6px 26px 0; font-size: 13px; font-weight: 700; z-index: 3; } .screen .mk-screen { position: absolute; inset: 0; overflow: auto; min-height: 0; padding: 50px 14px 96px; gap: 14px; scrollbar-width: none; } .screen .mk-headline { font-size: 34px; } +.screen .mk-pages { position: absolute; } +.statuses { display: flex; flex-wrap: wrap; gap: 12px; padding: 18px; border-radius: 28px; background: var(--mk-bg-art), var(--mk-bg); color: var(--mk-ink); } +.statuses .mk-glass { flex: 1 1 200px; padding: 16px; display: grid; gap: 6px; } +.statuses .mk-status { font-size: 20px; } .screen .tabdock { position: absolute; left: 14px; right: 14px; bottom: 14px; z-index: 3; } .caption { text-align: center; margin-top: 12px; font-size: 13px; font-weight: 700; color: var(--mk-ink-2); } .tablet { width: 860px; max-width: 100%; aspect-ratio: 16 / 9.4; border-radius: 34px; padding: 12px; background: #0b0b0f; box-shadow: 0 40px 80px -30px rgb(0 0 0 / .45); } @@ -697,7 +741,7 @@

Good evening.
All closed.

Gate
-
Closed
Live
+
Closed
Live
@@ -745,6 +789,14 @@

Good evening.
All closed.

A device page · hold “Open”, then tap “Close”
+ +
+
+
Pages · tap a tile, then swipe back from the left edge
+
@@ -758,7 +810,7 @@

Good evening.
Gate open. - GateOpenfor 4 min + GateOpenfor 4 min

Today
-
8.4 kWh
-
≈ $1.72 · 12 % less than a usual Friday
+
8.4 kWh
+
≈ $1.72 · meter last heard 6 min ago
Hour by hourpeak at 7 pm
@@ -845,11 +897,10 @@

Good evening.
Gate open.Heaters1.6 kWh

Everything else1.9 kWh
-
Meter silent for 6 min · last known values
-
Energy
+
Energy · an old value is dimmed, never bannered
@@ -910,6 +961,14 @@

States an interface must show

Stale value
+

Colours mean something · always with a word

+
+
ClosedNormal and safe: closed, locked, armed. A discreet green word and dot, never a green block.
+
OpenTo look at: open, moving, battery low.
+
OnOn or running: a light, the heating. Gold, like the lit tiles.
+
AlarmOnly an alarm or a failure. Red everywhere else teaches people to ignore it.
+
+

Principles

A remote, not a dashboardThe home screen opens pages and fills one screen, no more. One page per thing. Upkeep (batteries, silent sensors) goes elsewhere.
@@ -920,17 +979,421 @@

Principles

Open: never by accidentActions that open, unlock or start sit together on the first row and are held to confirm. Never from the home screen.
Close: never forgottenAlone on its row, one tap. While something is open it becomes the gold button, and the home tile turns warm with a Close shortcut.
Buttons never moveOpen or closed, each button keeps its place; only its emphasis changes. The thumb learns where to tap.
-
Say when the home goes quietDim stale values, show an honest banner, disable controls. Never a fake “up to date”.
+
Old values are dimmed, not banneredThe Miakapp bar already says “Connecting…”. Your app only dims the values that may be old and disables the controls. Never a banner of your own, never a fake “up to date”.
+
An app, not a web pagePages slide in and have a history entry each; a swipe from the left edge goes back. Text is not selectable, except in fields.
+