mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-23 02:31:31 +00:00
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:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>/)
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user