feat(gui): let the app drawer's intro flash retire once learned

The control drawer on headless dashboard apps opened itself on EVERY
window open, forever -- 2.6 seconds of tray over the app's own top
edge to teach a lesson (the controls live in this tongue) that is
learned in a few exposures. Same doctrine as the dashboard's
deep-link intro: pedagogy that knows when to step aside.

A per-account kv counter records deliveries, once per window: a flash
played while the user could see it, or the user opening the drawer
themselves (hover, tap, focus) -- the stronger proof, since the
drawer is a headless app's only chrome and must never decay out of a
user's awareness. After three, new windows keep the bare tongue and
the flash stops; hover, tap, and focus expansion never decay.

The attach is synchronous and the stored count is not: the first
drawer of a session races the read against a short cap and defaults
to teaching -- a hung read must not hold the intro hostage, and every
failure mode (unparseable value, failed read, timed-out read, failed
write) errs toward teaching once more, never toward never teaching.
The resolved count is cached and kept current in memory, so later
windows decide synchronously and a session's own not-yet-written
increments still count. kv.incr keeps two devices from losing an
increment, and the pre-read caps the key once the lesson is learned.

If the user beats a still-deciding flash to the drawer, the flash is
skipped rather than played over their hand -- its auto-collapse would
shut a drawer they are actively using. A flash in a hidden tab still
defers to first reveal (it rides requestAnimationFrame, as before)
and an unseen one is never counted.
This commit is contained in:
jelveh
2026-08-08 10:25:26 -07:00
parent 95da802b13
commit 66dedf3082
+122 -7
View File
@@ -4502,6 +4502,72 @@ function dashboard_rendered_app_icon (app_name) {
return null;
}
// -- Drawer intro exposure decay --
// The drawer's opening flash (see attach_dashboard_app_drawer) is pedagogy:
// it teaches that the app's controls live in the tongue. Like the
// dashboard's deep-link intro (TabApps.js), the lesson decays — after this
// many deliveries, new windows keep the bare tongue and the flash stops
// occluding the app's top edge. A delivery is counted once per window:
// either the flash played while the user could see it, or the user opened
// the drawer themselves (hover, tap, focus) — the stronger proof, since the
// drawer is a headless app's only chrome and must never decay out of a
// user's awareness. Counted per ACCOUNT in kv, not per device, for the same
// reasons as DEEP_LINK_INTRO_SEEN_KV_KEY: the lesson lives in the user's
// head and follows them across devices, and localStorage would bleed
// between accounts on a shared browser.
const DRAWER_INTRO_TEACH_COUNT = 3;
const DRAWER_INTRO_SEEN_KV_KEY = 'dashboard_drawer_intros_seen';
// How long the first drawer of a session waits for the stored count before
// defaulting to "teach" — a hung kv read must not hold the intro hostage,
// and timing out errs toward teaching once more, never toward never
// teaching.
const DRAWER_INTRO_KV_WAIT_MS = 400;
// The session's view of the count: one kv read per session, then kept
// current in memory so every later window decides synchronously — and
// correctly mid-session, while this session's own increments are still in
// flight.
let drawer_intro_count = null;
let drawer_intro_read = null;
// The kv counter arrives as whatever the store returns (a number, a numeric
// string, null on a failed read); anything unparseable reads as zero.
function parse_drawer_intro_count (raw) {
const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
function read_drawer_intro_count () {
if ( ! drawer_intro_read ) {
drawer_intro_read = Promise.resolve()
.then(() => puter.kv.get(DRAWER_INTRO_SEEN_KV_KEY))
.then(raw => {
// Merge, never overwrite: a flash this session may have
// bumped the local count before the read resolved.
drawer_intro_count = Math.max(drawer_intro_count ?? 0, parse_drawer_intro_count(raw));
return drawer_intro_count;
})
.catch(() => (drawer_intro_count = drawer_intro_count ?? 0));
}
return drawer_intro_read;
}
// Record one delivery. The session cache bumps first so the very next
// window decides correctly even while the write is in flight or failing;
// kv.incr is atomic on the server, so two devices landing at once can't
// lose an increment, and the awaited read only CAPS the write — once the
// lesson is learned there is nothing left to record and the key stops
// changing. A failed write just means one more teach later.
function record_drawer_intro_seen () {
drawer_intro_count = (drawer_intro_count ?? 0) + 1;
read_drawer_intro_count().then(() => {
if ( drawer_intro_count > DRAWER_INTRO_TEACH_COUNT ) return;
return puter.kv.incr(DRAWER_INTRO_SEEN_KV_KEY);
}).catch(err => {
console.error('Failed to record the drawer intro exposure:', err);
});
}
/**
* The control drawer for headless dashboard app windows: one glass surface
* flush with the top edge of the app (parent DOM, above the app's iframe)
@@ -4518,6 +4584,11 @@ function dashboard_rendered_app_icon (app_name) {
* invisible to the parent — the drawer itself is the only hover surface
* there is.
*
* The self-introducing flash carries exposure decay (see the Drawer intro
* section above): once the account has seen the controls enough times —
* flash or their own hand — new windows skip it and keep the tongue.
* Hover, tap, and focus never decay; only the automatic flash does.
*
* The drawer is a CHILD of the window element, so it shows/hides/scales with
* the window for free (minimize morphs, display:none, fullscreen requests
* from the iframe hide it via the browser's own fullscreen stacking).
@@ -4592,26 +4663,44 @@ function attach_dashboard_app_drawer (el_window, options) {
clearTimeout(collapse_timer);
collapse_timer = setTimeout(collapse, ms);
};
// Expand + auto-collapse: played once on open, so the controls
// introduce themselves — and retract INTO the tongue, teaching where
// they live — without permanently costing pixels.
// Expand + auto-collapse: played once on open (while the account is
// still learning — see below), so the controls introduce themselves —
// and retract INTO the tongue, teaching where they live — without
// permanently costing pixels.
const flash = () => {
expand();
schedule_collapse(2600);
};
// Proof of learning, counted once per window: the flash delivered while
// the user could see it, or — the stronger signal — the user opening
// the drawer themselves. user_expand is the expansion every deliberate
// route goes through (hover, tap, focus); the flash records separately.
let intro_recorded = false;
const record_intro_once = () => {
if ( intro_recorded ) return;
intro_recorded = true;
record_drawer_intro_seen();
};
let user_opened = false;
const user_expand = () => {
user_opened = true;
record_intro_once();
expand();
};
// Hover pulls the drawer open and leaving lets it settle — mouse only:
// a touch tap synthesizes a pointerenter right before its click, which
// would make the toggle below see an already-open drawer and shut it
// again. Touch devices open by tap instead, and since they never fire
// pointerleave, that path self-schedules its collapse.
$drawer.on('pointerenter', (e) => {
if ( e.pointerType === 'mouse' ) expand();
if ( e.pointerType === 'mouse' ) user_expand();
});
$drawer.on('pointerleave', (e) => {
if ( e.pointerType === 'mouse' ) schedule_collapse(900);
});
$drawer.on('focusin', () => expand());
$drawer.on('focusin', () => user_expand());
$drawer.on('focusout', () => schedule_collapse(1100));
// Pressing the drawer activates its window, as pressing a titlebar
@@ -4631,7 +4720,7 @@ function attach_dashboard_app_drawer (el_window, options) {
$(toggle).on('click', function (e) {
e.stopPropagation();
if ( drawer.classList.contains('collapsed') ) {
expand();
user_expand();
schedule_collapse(3500);
} else if ( Date.now() - opened_at < 500 ) {
schedule_collapse(3500);
@@ -4661,9 +4750,35 @@ function attach_dashboard_app_drawer (el_window, options) {
el_window._dashboard_drawer_collapse = collapse;
$(el_window).append($drawer);
// The intro flash, gated by exposure decay. The stored count is raced
// against a short cap: the first drawer of a session may not have it
// yet, and in practice the read (started here, resolved for good by
// the session cache) beats the window's own open animation — later
// windows decide synchronously.
const seen_promise = read_drawer_intro_count();
// Two frames so the collapsed state paints first and the intro
// morphs out of the tongue instead of popping in fully open.
requestAnimationFrame(() => requestAnimationFrame(flash));
requestAnimationFrame(() => requestAnimationFrame(async () => {
const seen = drawer_intro_count !== null
? drawer_intro_count
: await Promise.race([
seen_promise,
new Promise(resolve => setTimeout(() => resolve(null), DRAWER_INTRO_KV_WAIT_MS)),
]);
// A timed-out read (null) reads as zero: teach once more.
if ( (seen ?? 0) >= DRAWER_INTRO_TEACH_COUNT ) return;
// The user beat the intro to the drawer (hover, tap, focus) while
// the count was being fetched: there is nothing left to introduce,
// and the flash's auto-collapse would shut a drawer they are
// actively using.
if ( user_opened || ! drawer.classList.contains('collapsed') ) return;
// The window can already be gone (closed during its own open).
if ( ! drawer.isConnected ) return;
flash();
// A flash nobody could see taught nothing — don't count it.
if ( document.visibilityState !== 'hidden' ) record_intro_once();
}));
}
/**