The frame draws only sound doors, mounts the scope once, and acts only on plain ids

A section points at a path of the app or an https address on another host;
a protocol-relative, script or bare address is not drawn, and the home mark
falls back to the app's root. The scope renders in exactly one place, the bar
or the phone's sheet, so there is one selection and one set of reads. A
notification is marked read or archived only when its id is a single plain
segment, and a reason the server names is looked up in a Map. The scope's
keys are per workspace, not per person, and the README says the host clears
them when the person changes. `.auth/` is ignored.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-25 01:52:44 -07:00
co-authored by Hanzo Dev
parent faed55df10
commit ba57d99f89
7 changed files with 87 additions and 21 deletions
+3
View File
@@ -93,3 +93,6 @@ apps/demos/types/**/*.d.ts.map
CLAUDE.md
AGENTS.md
GEMINI.md
# Browser session state a test saved. It holds a live credential.
.auth/
+5 -1
View File
@@ -246,7 +246,11 @@ import { Frame, Account, Scope, useScope, Beside, useFrame } from '@hanzogui/she
- **Scope**: project and environment, one resource scope in the bar.
`useScope(org)` keeps the last project per workspace and restores it on a
switch, or falls back visibly to All projects; no environment is drawn that
the platform did not name.
the platform did not name. Its keys (`hanzo:scope:*`) are per workspace, not
per person: the host clears them when the person changes, as hanzo.ai and the
Hanzo App do for every `hanzo*` key.
- **Sections** point at a path of the app (`/…`) or an `https://` address on
another host, which is drawn as a link. Anything else is not drawn.
- **Beside**: a room puts content in the column beside it; the frame owns the
column, the room owns what is in it.
- **Notifications**: the reader's own (`/v1/team/inbox`), read through the
+27
View File
@@ -176,3 +176,30 @@ test('notifications ask through the host and hold no credential', () => {
for (const path of ["'/v1/team/inbox'", '/read`', '/archive`'])
assert.ok(src.includes(path), path)
})
test('a section points at a path here or an https address elsewhere, and nothing else', () => {
const html = render({
sections: [
...SECTIONS,
{ id: 'proto', label: 'Proto', icon: Mark, href: '//evil.example' },
{ id: 'script', label: 'Script', icon: Mark, href: 'javascript:alert(1)' },
{ id: 'bare', label: 'Bare', icon: Mark, href: '@evil.example' },
{ id: 'plain', label: 'Plain', icon: Mark, href: 'http://hanzo.team' },
],
home: '//evil.example',
})
for (const label of ['Proto', 'Script', 'Bare', 'Plain'])
assert.doesNotMatch(
html,
new RegExp(`aria-label="${label}"`),
`${label} is not drawn`
)
assert.match(html, /<a href="\/" data-slot="home"/, 'the mark falls back to this app')
})
test('a notification is acted on only by a plain id, and its reason is looked up safely', () => {
const src = read('Notifications.tsx')
assert.match(src, /const SEGMENT = \/\^\[A-Za-z0-9_-\]\+\$\//)
assert.match(src, /SEGMENT\.test\(n\.id\)/)
assert.match(src, /new Map<string, string>/)
})
+26 -9
View File
@@ -43,6 +43,7 @@ import { MARKS } from './glyph.tsx'
import { HanzoMark } from './mark.tsx'
import { useFrameStyles } from './shellStyles.ts'
import { FRAME } from './theme.ts'
import { useMediaQuery } from './useMediaQuery.ts'
/** One place the rail goes. */
export interface Section {
@@ -261,7 +262,16 @@ function Grip({
)
}
const external = (href: string) => /^https?:\/\//.test(href)
/**
* WHERE A SECTION MAY POINT: a path of this app, or an https address on another
* host — nothing else. A protocol-relative `//host`, a `javascript:` or a bare
* word would otherwise reach the host's router or an `<a>` as though it were a
* place, and a router that prefixes addresses turns `@host` into somebody
* else's site.
*/
const external = (href: string) => /^https:\/\//i.test(href)
const internal = (href: string) => /^\/(?![/\\])/.test(href)
const sound = (href: string) => external(href) || internal(href)
/**
* A section, as the host's router moves to it — or as a plain link when it
@@ -535,6 +545,13 @@ export function Frame({
}, [])
const edge = `1px solid ${FRAME.edge}`
const doors = sections.filter((section) => sound(section.href))
const door = sound(home) ? home : '/'
// THE SCOPE IS MOUNTED ONCE: in the bar where there is room for it, at the
// top of the sheet on a phone. Drawn in both and hidden in one, it was two
// selections and two sets of reads that could disagree. The first render is
// the wide form, which is what a prerendered page and a laptop both expect.
const narrow = useMediaQuery('(max-width: 767.98px)')
return (
<Frames.Provider value={state}>
@@ -547,7 +564,7 @@ export function Frame({
className={className}
>
<Rail
sections={sections}
sections={doors}
active={active}
navigate={navigate}
settings={settings}
@@ -582,7 +599,7 @@ export function Frame({
<MARKS.forward size={16} />
</Control>
) : null}
{scope ? (
{scope && !narrow ? (
<div
data-slot="scope"
data-frame-md=""
@@ -673,7 +690,7 @@ export function Frame({
}}
>
<a
href={home}
href={door}
data-slot="home"
aria-label="Hanzo home"
title="Home"
@@ -687,7 +704,7 @@ export function Frame({
)
return
e.preventDefault()
navigate(home)
navigate(door)
}}
style={{
display: 'flex',
@@ -707,10 +724,10 @@ export function Frame({
</div>
) : null}
</div>
{/* The scope's second home: a phone's bar has no room for it. */}
{scope ? (
{/* The scope's home on a phone, whose bar has no room for it. */}
{scope && narrow ? (
<div
data-frame-sm=""
data-slot="scope"
style={{ display: 'flex', minWidth: 0, padding: '0 8px 4px' }}
>
{scope}
@@ -725,7 +742,7 @@ export function Frame({
padding: '0 8px 8px',
}}
>
{sections.map((section) => (
{doors.map((section) => (
<Door
key={section.id}
section={section}
+20 -10
View File
@@ -50,13 +50,22 @@ export interface NotificationsProps {
const INBOX = '/v1/team/inbox'
const REASON: Record<string, string> = {
mention: 'Mentioned you',
dm: 'Direct message',
reply: 'Replied in a thread',
comment: 'Commented',
assigned: 'Assigned to you',
}
/** A Map, not an object: a reason the server names is data, and `__proto__` is a key. */
const REASON = new Map<string, string>([
['mention', 'Mentioned you'],
['dm', 'Direct message'],
['reply', 'Replied in a thread'],
['comment', 'Commented'],
['assigned', 'Assigned to you'],
])
/**
* An id that is one path segment and nothing more. It is spliced into
* `/v1/team/inbox/{id}/…`, and an id of `.` or `..` survives URL encoding and
* is then resolved as a segment — `.` would turn "mark this read" into "mark
* all read". A notification whose id is not a plain token is not acted on.
*/
const SEGMENT = /^[A-Za-z0-9_-]+$/
/** Today a clock, this year a date, before that the year too. */
function when(ms: number | undefined, now: Date): string {
@@ -118,7 +127,7 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
setItems((was) => (was ?? []).map((n) => (n.id === id ? { ...n, ...patch } : n)))
const open = async (n: Notice) => {
if (!n.read) {
if (!n.read && SEGMENT.test(n.id)) {
touch(n.id, { read: true })
setUnread((u) => Math.max(0, u - 1))
try {
@@ -138,6 +147,7 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
}
const archive = async (n: Notice) => {
if (!SEGMENT.test(n.id)) return
const before = items
setItems((was) => (was ?? []).filter((x) => x.id !== n.id))
if (!n.read) setUnread((u) => Math.max(0, u - 1))
@@ -250,13 +260,13 @@ export function Notifications({ call, onOpen }: NotificationsProps) {
>
<span style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
<span style={{ flex: 1, fontWeight: n.read ? 400 : 600 }}>
{REASON[n.reason ?? ''] ?? 'Notification'}
{REASON.get(String(n.reason ?? '')) ?? 'Notification'}
</span>
<span style={{ flex: 'none', fontSize: 11, color: FRAME.soft }}>
{when(n.createdOn, now)}
</span>
</span>
{n.message?.text ? (
{typeof n.message?.text === 'string' && n.message.text ? (
<span
style={{
color: FRAME.soft,
+5
View File
@@ -13,6 +13,11 @@
*
* An environment list the platform has not given is not invented: with none,
* no environment control is drawn.
*
* THE KEYS ARE PER WORKSPACE, NOT PER PERSON. Two people who share a browser
* and a workspace would share the last project, so the host clears
* `hanzo:scope:*` when the person changes — hanzo.ai and the Hanzo App do, with
* every other `hanzo*` key, in `own()`. A host without that sweep must add it.
*/
import React, { useCallback, useEffect, useState } from 'react'
import { FRAME, FS } from './theme.ts'
+1 -1
View File
@@ -192,7 +192,7 @@ const FRAME_CSS = [
// The column beside the room exists from 1024px, and so do the controls that
// open it: a toggle for a column nobody can see is a dead control.
`@media (max-width:1023.98px){${F}>[data-slot=side],${F} [data-frame-lg]{display:none!important}${F}>[data-slot=panel]{position:absolute;inset:44px 0 0;z-index:var(--z-drawer,20)}}`,
`@media (min-width:768px){${F} [data-frame-sm]{display:none!important}${F}>[data-slot=scrim]{display:none}${F}>[data-slot=rail] [data-slot=more]{display:none}${F}[data-sidebar=open]:not([data-strip]){grid-template-columns:0 auto minmax(0,1fr) auto}${F}[data-sidebar=open]:not([data-strip])>[data-slot=rail]{display:none}${F}[data-strip] [data-slot=places-list]{display:none}}`,
`@media (min-width:768px){${F}>[data-slot=scrim]{display:none}${F}>[data-slot=rail] [data-slot=more]{display:none}${F}[data-sidebar=open]:not([data-strip]){grid-template-columns:0 auto minmax(0,1fr) auto}${F}[data-sidebar=open]:not([data-strip])>[data-slot=rail]{display:none}${F}[data-strip] [data-slot=places-list]{display:none}}`,
// A PHONE. The bar, the room, and the rail as the tab bar along the bottom:
// four sections and More, the rest one press away in the finder. The sidebar
// is a sheet between the bar and the tab bar, and the words of the search