Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified capsules/assistant/browser/icons/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified capsules/assistant/browser/icons/icon-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified capsules/assistant/browser/icons/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified capsules/assistant/browser/icons/icon-64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 22 additions & 1 deletion capsules/home-gui/browser/home-gui-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,28 @@ <h1 class="launcher-heading visually-hidden">Apps</h1>
hidden
>
<span class="taskbar-icon">
<span class="taskbar-item-icon"></span>
<!-- Home's Assistant mark: orange chevron beneath the frosted glass one,
composited live. Motion lives in shell-assistant-mark.js. -->
<span class="taskbar-item-icon assistant-mark" data-tone="raster">
<img
class="assistant-mark-layer"
data-assistant-layer="orange"
src="./icons/assistant-mark/orange/icon-128.png?v=home-20260813a"
srcset="./icons/assistant-mark/orange/icon-128.png?v=home-20260813a 128w, ./icons/assistant-mark/orange/icon-256.png?v=home-20260813a 256w"
sizes="64px"
alt=""
draggable="false"
>
<img
class="assistant-mark-layer"
data-assistant-layer="glass"
src="./icons/assistant-mark/glass/icon-128.png?v=home-20260813a"
srcset="./icons/assistant-mark/glass/icon-128.png?v=home-20260813a 128w, ./icons/assistant-mark/glass/icon-256.png?v=home-20260813a 256w"
sizes="64px"
alt=""
draggable="false"
>
</span>
</span>
</button>
<button
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 13 additions & 9 deletions capsules/home-gui/browser/shell-assistant-face.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
normalizeHomeAgentBrowserUrl,
normalizeHomeAgentViewerPayload,
} from "./home-agent-message-contract.js?v=home-20260813a";
import { bindAssistantMark } from "./shell-assistant-mark.js?v=home-20260813a";

const FACE_ID = "assistant-face";
const TARGET_ID = "assistant";
Expand Down Expand Up @@ -431,8 +432,9 @@ function onFrameMessage(event) {

/* ---- Face ------------------------------------------------------------------ */

/* The toggle exists only when the capsule is installed; it wears the capsule's
own declared icon like every other dock item. */
/* The toggle exists only when the capsule is installed. Unlike other dock
items it wears Home's own Assistant mark (shell-assistant-mark.js), not the
capsule's icon: the mark is part of the face cadence Home owns. */
export function syncAssistantFaceAvailability(summary) {
const toggle = toggleEl();
if (!toggle || !deps) {
Expand All @@ -441,9 +443,7 @@ export function syncAssistantFaceAvailability(summary) {
browserAvailable = Boolean(deps.targetById(summary, "browser"));
const target = deps.targetById(summary, TARGET_ID);
toggle.hidden = !target;
if (target) {
deps.mountGlyph(toggle.querySelector(".taskbar-item-icon"), TARGET_ID);
} else if (assistantFaceActive()) {
if (!target && assistantFaceActive()) {
retireAssistantSpace();
}
}
Expand Down Expand Up @@ -531,7 +531,7 @@ export function hideAssistantFace({ instant = false } = {}) {
setFaceOpen(false);
setPhase(taskbar, "");
closing = false;
toggleEl()?.focus({ preventScroll: true });
deps.restoreDockFocus(toggleEl());
};

if (instant || reducedMotion()) {
Expand Down Expand Up @@ -584,7 +584,7 @@ export function hideAssistantFace({ instant = false } = {}) {
after(RETURN_MS + 16, () => {
setPhase(taskbar, "");
closing = false;
toggleEl()?.focus({ preventScroll: true });
deps.restoreDockFocus(toggleEl());
});
});
});
Expand All @@ -602,8 +602,8 @@ export function toggleAssistantFace() {
* Wire the face once the GUI template is in the DOM.
* @param {{
* easeDockPillWidth: (fromW: number, durationName?: string) => void,
* restoreDockFocus: (item: HTMLElement | null) => void,
* targetById: (summary: unknown, targetId: string) => unknown,
* mountGlyph: (container: Element | null, targetId: string) => void,
* launchHomeTarget: (targetId: string, query: object) => Promise<object>,
* iframeSandboxForLaunch: (launched: object) => string,
* iframeAllowForLaunch: (launched: object) => string,
Expand All @@ -630,7 +630,11 @@ export function bindAssistantFace(dependencies) {
open: () => showAssistantFace(),
close: () => hideAssistantFace(),
});
toggleEl()?.addEventListener("click", () => toggleAssistantFace());
bindAssistantMark(toggleEl(), {
onActivate: () => toggleAssistantFace(),
/* Only an opening earns the chevron contact; closing is immediate. */
animate: () => !assistantFaceActive() && !taskbarEl()?.dataset.assistantMorph,
});
document.querySelector("#assistant-space-retry")?.addEventListener("click", () => {
void mountAssistantFrame();
});
Expand Down
128 changes: 128 additions & 0 deletions capsules/home-gui/browser/shell-assistant-mark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* Assistant mark: the Elastos chevrons on the dock toggle.

Two layers in one square — a frosted-glass upper chevron over the complete
orange lower chevron — composited live, so the orange seen through the glass
is real alpha, not baked into the glass artwork. Still at rest: no load,
hover or idle motion.

A real activation (pointer or keyboard click) plays the whole contact
before anything else moves: the chevrons close toward each other
(CONTACT_MS), then return to rest (RETURN_MS), and only then does the face
open. Equal and opposite travel keeps both chevrons exactly aligned at
contact. The return ends on the resting transform, so cancelling the
filled animations afterwards changes nothing on screen.

Presentation only — no authority. Repeat clicks are ignored until the
sequence settles; reduced motion opens immediately. */

import { prefersReducedMotion } from "./shell-motion.js?v=home-20260813a";

export const ASSISTANT_MARK_CONTACT_MS = 240;
export const ASSISTANT_MARK_RETURN_MS = 170;
/* Share of the square the glass travels down; the orange travels the same
share up, so the chevrons meet exactly. */
const CONTACT_TRAVEL = "10.35%";
const REST = "0";
const EASE_CLOSE = "cubic-bezier(0.32, 0, 0.18, 1)";
const EASE_RETURN = "cubic-bezier(0.2, 0, 0, 1)";

function layerEl(root, layer) {
return root.querySelector(`[data-assistant-layer="${layer}"]`);
}

function opposite(travel) {
return travel === REST ? REST : `-${travel}`;
}

function translateFrames(from, to) {
return [
{ transform: `translate3d(0, ${from}, 0)` },
{ transform: `translate3d(0, ${to}, 0)` },
];
}

/* Cancelled animations reject — that only means the mark was torn down. */
async function settled(animations) {
try {
await Promise.all(animations.map((animation) => animation.finished));
} catch {
/* torn down mid-flight */
}
}

/* Both chevrons travel together, the orange mirroring the glass. */
function moveChevrons(art, from, to, duration, easing) {
const options = { duration, easing, fill: "forwards" };
return [
layerEl(art, "glass").animate(translateFrames(from, to), options),
layerEl(art, "orange").animate(translateFrames(opposite(from), opposite(to)), options),
];
}

/* Close, return to rest, then release the filled transforms. */
async function playContact(art) {
const close = moveChevrons(art, REST, CONTACT_TRAVEL, ASSISTANT_MARK_CONTACT_MS, EASE_CLOSE);
await settled(close);
const back = moveChevrons(art, CONTACT_TRAVEL, REST, ASSISTANT_MARK_RETURN_MS, EASE_RETURN);
await settled(back);
[...close, ...back].forEach((animation) => animation.cancel());
}

/**
* Wire the mark on the Assistant toggle.
* @param {HTMLElement | null} button
* @param {{
* onActivate: () => void,
* animate?: () => boolean,
* reducedMotion?: () => boolean,
* }} options `animate` lets the caller skip the contact when the activation
* is not an opening (closing an open face needs no chevron motion).
* @returns {() => void} unbind
*/
export function bindAssistantMark(button, options) {
if (!button) {
return () => {};
}
const shouldAnimate = options.animate ?? (() => true);
const reducedMotion = options.reducedMotion ?? prefersReducedMotion;
let running = false;
let bound = true;

async function handleClick(event) {
event.preventDefault();
if (running) {
return;
}
const art = button.querySelector(".assistant-mark");
if (!art || reducedMotion() || !shouldAnimate()) {
options.onActivate();
return;
}
running = true;
button.dataset.animating = "true";
try {
await playContact(art);
} catch {
/* Motion is presentation only: a missing layer or a failed animate()
must not keep the face shut. */
} finally {
/* Always release the guard, or every later click is ignored and the
toggle is dead until reload. */
delete button.dataset.animating;
running = false;
}
/* The contact announced an opening. If the face opened (or started to
move) by another path meanwhile, or the mark was unbound, that opening
is stale: activating now would toggle the face shut instead. */
if (!bound || !shouldAnimate()) {
return;
}
options.onActivate();
}

button.addEventListener("click", handleClick);
return () => {
bound = false;
button.removeEventListener("click", handleClick);
};
}
22 changes: 20 additions & 2 deletions capsules/home-gui/browser/shell-surface.js
Original file line number Diff line number Diff line change
Expand Up @@ -2696,6 +2696,7 @@ const dockState = {
tooltipShowTimer: 0,
tooltipHideTimer: 0,
tooltipAnchor: null,
restoringFocus: false,
};

function dockMagnifyEnabled() {
Expand Down Expand Up @@ -2895,6 +2896,21 @@ function hideDockTooltip() {
}
}

/* Return focus to a dock item after a face or popover closes over it. The
label only ever answers the person's own hover or keyboard arrival, so this
focus — Home's, not theirs — is kept quiet. */
function restoreDockFocus(item) {
if (!item) {
return;
}
dockState.restoringFocus = true;
try {
item.focus({ preventScroll: true });
} finally {
dockState.restoringFocus = false;
}
}

function startDockLaunchBounce(item) {
if (dockReducedMotion()) {
return;
Expand Down Expand Up @@ -2955,6 +2971,8 @@ function setupDock() {

// Tooltips by delegation so they survive taskbar re-renders. Shown on hover
// (after a delay) and on keyboard focus (immediately); Escape dismisses.
// Focus Home hands back itself (restoreDockFocus) is not the person
// arriving at the item, so it raises no label.
taskbar.addEventListener("pointerover", (event) => {
const item = event.target.closest(".taskbar-item");
if (!item || (event.relatedTarget && item.contains(event.relatedTarget))) {
Expand All @@ -2971,7 +2989,7 @@ function setupDock() {
});
taskbar.addEventListener("focusin", (event) => {
const item = event.target.closest(".taskbar-item");
if (item) {
if (item && !dockState.restoringFocus) {
scheduleDockTooltip(item, 0);
}
});
Expand Down Expand Up @@ -3008,8 +3026,8 @@ export function bindShellSurfaceDom(options = {}) {
desktopContextMenu?.addEventListener("keydown", handleContextMenuKeydown);
bindAssistantFace({
easeDockPillWidth,
restoreDockFocus,
targetById,
mountGlyph,
launchHomeTarget,
iframeSandboxForLaunch,
iframeAllowForLaunch,
Expand Down
27 changes: 27 additions & 0 deletions capsules/home-gui/browser/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3830,6 +3830,33 @@ body.dock-autohide::after {
--dock-shift: 0px;
}

/* Assistant mark: both chevron layers fill the same square so their equal
and opposite travel meets exactly. Transform-only, still at rest; the
sequence is driven from shell-assistant-mark.js. */
.assistant-mark {
position: relative;
overflow: visible;
}

/* The chevron artwork spans 64.5% of its square; the launcher glyph spans
58.6% of its own. Drawing the layers at 90.9% of the slot puts the mark's
top and bottom on the launcher glyph's. Explicit size: an absolutely
positioned image keeps its intrinsic size under width:auto. */
.assistant-mark-layer {
position: absolute;
top: 4.55%;
left: 4.55%;
width: 90.9%;
height: 90.9%;
display: block;
object-fit: contain;
transform: translate3d(0, 0, 0);
transform-origin: 50% 50%;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}

.taskbar-icon {
position: relative;
width: 100%;
Expand Down
5 changes: 4 additions & 1 deletion capsules/model-provider/src/journal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4220,7 +4220,10 @@ mod tests {
}
let temp_before = file_bytes(&temp);
let writer = open_journal_directory(&directory).unwrap();
writer.try_lock().unwrap();
// A child forked on another test thread can briefly hold a copy of an
// earlier lock fd on this directory (#381); take the lock with the
// provider's own bounded retry instead of one try.
lock_journal_directory(&writer).unwrap();
assert_eq!(
RunJournal::open(root.clone()).err().unwrap().code(),
"selection_unavailable"
Expand Down
Loading
Loading