The first-run screens for EmojiCopy for Mac. A welcome, then four steps: typing access, the quick trigger, where the app lives, and a live test. The last two cards sit outside the flow.
SetupWindow · new — the flow shell: named rail, centred body, scrimmed footer. Fills the EXISTING 800 × 600 main window. Not a modal, no backdrop, no second window.Stepper · ui/components/Stepper.vue — four dots beside the button. Light-surface variant: the shipping dots are white-on-dark for the pop modal, these are metal-950 on white. No dots on the welcome, the guard or the recovery screen, because none of them is a step.MiniWindow · apps/desktop/src-tauri/src/window.rs — the real 405pt panel, cropped to 196pt and faded out. The welcome art is the product, not a drawing of it. Same markup as card E, so the two can never drift.Icon new names · app-window, power — Phosphor, on the step 3 setting rows. app-window is imported today, power is a new import in IconIndex.vue.Text · ui/components/Text.vue — every string on the surface..setup column · rail and footer are fixed-height siblings, the body takes the rest and centres its own column. Grid would need a fixed track for the body, which changes height per step (R10).<script setup>
import { computed } from 'vue'
import Text from '@/components/Text.vue'
import SetupSteps from '@/components/setup/SetupSteps.vue'
const props = defineProps({
// null on the welcome screen, the guard and the recovery surface. None of
// the three is a step, so none of them shows dots
step: { type: Number, default: null },
})
const emit = defineEmits(['later'])
</script>
<template>
<div class="setup">
<div class="setup__body">
<div class="setup__col">
<slot name="art" />
<slot name="head" />
<slot />
</div>
</div>
<div class="setup__foot">
<SetupSteps v-if="props.step" :step="props.step" :total="4" />
<slot name="cta" />
<slot name="note" />
</div>
</div>
</template>
<script setup>
// A light-surface wrapper around the shipping Stepper. Nothing new: the dot
// markup, the aria-* attributes and the transitions are Stepper.vue's, the
// only change is the palette, since Stepper ships white-on-dark for the pop
// modal and this window is white.
const props = defineProps({
step: { type: Number, required: true },
total: { type: Number, default: 4 },
})
</script>
<template>
<div
class="dots"
role="progressbar"
:aria-valuenow="props.step"
:aria-valuemin="1"
:aria-valuemax="props.total"
:aria-label="`Step ${props.step} of ${props.total}`"
>
<span
v-for="i in props.total"
:key="i"
class="dot"
:class="{ 'dot--on': i === props.step }"
/>
</div>
</template>
<SetupWindow :step="null" @later="dismissSetup">
<template #art><MiniPeek /></template>
<template #head>
<Text tag="h2" class="setup__title" text="Set up EmojiCopy" />
<Text type="body/m-reg" class="setup__sub"
text="Four steps. Change any of it later." />
</template>
<template #cta>
<button class="setup__cta" @click="go(1)">
<Text type="body/m-heavy" tag="span" text="Start setup" />
</button>
</template>
</SetupWindow>
.setup {
width: 800px;
height: 600px;
display: flex;
flex-direction: column;
background: var(--white);
font-family: var(--ff-inter);
}
/* Stepper.vue's dots, light-surface. They sit beside the button because a bar
across the top is navigation chrome for a flow with nowhere to navigate to */
.dots {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--metal-200);
transition: background .15s, box-shadow .2s;
&.dot--on {
background: var(--metal-950);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--metal-950) 7%, transparent);
}
}
}
/* The welcome art is the product. The real mini panel, cropped and faded, so
the first thing a new user sees is the emoji picker they installed rather
than an illustration of one. It reuses the panel markup verbatim, which
means the welcome cannot drift away from what the app actually renders. */
.peek {
position: relative;
width: 405px;
height: 196px;
overflow: hidden;
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 16, 18, .20);
/* fades into the window rather than stopping on a hard edge, so it reads
as a view into the app and not as a pasted-in screenshot */
mask-image: linear-gradient(#000 68%, transparent 99%);
}
.setup__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 24px 28px 0;
text-align: center;
.setup__col {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
}
/* pinned, like `.pop-modal__title`. The DS heading ramp is viewport-fluid
(`clamp(2.6rem, 3vw, 3.9rem)`) and would resize with the browser instead
of with the 800pt window it lives in. */
.setup__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 27px;
line-height: 1.15;
letter-spacing: -.03em;
color: var(--metal-950);
}
.setup__foot {
position: relative;
flex: none;
padding: 0 28px 22px;
display: flex;
flex-direction: column;
align-items: center;
gap: 9px;
/* the reassurance caption sits on a fade, not under a rule. A divider
would read as a second section and give the caption button weight */
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: -32px;
height: 32px;
background: linear-gradient(to top, var(--white), transparent);
pointer-events: none;
}
}
.setup__cta {
min-width: 232px;
height: 38px;
border-radius: var(--r-sm);
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--green-600);
color: var(--metal-950);
cursor: pointer;
}
PermissionStatusRow · new — three states, because Allowed and Working are genuinely different things here. State is carried by shape AND word: dashed ring not on, hollow check allowed, filled check observed working.SettingsMap · new — a DRAWN map of the Accessibility list in EmojiCopy's own tokens. It aids pattern matching without pretending to be the system, and it does not rot when Apple restyles the pane.SetupArtInsert · new — the capability drawn. An emoji arcs into a text field. Shown on priming and on allowed, hidden while waiting so the status row leads.request_accessibility_permission · apps/desktop/src-tauri/src/input.rs — already shipping. It is what raises the macOS window..prow row · mark, stacked label, then an action pushed right with margin-left:auto. Flex because the action is optional and grid would leave its column behind (R10).<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import Text from '@/components/Text.vue'
import PermissionStatusRow from '@/components/setup/PermissionStatusRow.vue'
import SettingsMap from '@/components/setup/SettingsMap.vue'
import SetupArtInsert from '@/components/setup/SetupArtInsert.vue'
const ACCESSIBILITY_PANE =
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
const emit = defineEmits(['next', 'later'])
// 'priming' -> the macOS window has not been raised yet
// 'waiting' -> raised, not on
// 'allowed' -> the privilege reads as granted
const phase = ref('priming')
let poll = null
let stableFor = 0
const isAllowed = computed(() => phase.value === 'allowed')
const startPolling = () => {
if (poll) return
poll = setInterval(async () => {
// SILENT read. Never poll the prompting variant: it would re-raise
// the macOS window, and macOS only shows that window once
const state = await invoke('access_state')
// the trust bit is known to report wrong values right after a quick
// toggle, so a granted reading has to hold before the screen commits
stableFor = state.can_post ? stableFor + 1 : 0
if (stableFor >= 3) {
phase.value = 'allowed'
clearInterval(poll)
poll = null
// hold the flip long enough to be seen, then advance
setTimeout(() => emit('next'), 1400)
}
}, 400)
}
const requestAccess = async () => {
phase.value = 'waiting'
// raises the macOS window. One shot per identity and service
await invoke('request_accessibility_permission')
startPolling()
}
const openSettings = () => openUrl(ACCESSIBILITY_PANE)
onUnmounted(() => {
if (poll) clearInterval(poll)
})
</script>
<template>
<SetupWindow :step="1" @later="emit('later')">
<template v-if="phase !== 'waiting'" #art>
<SetupArtInsert :settled="isAllowed" />
</template>
<template #head>
<Text
tag="h2"
class="setup__title"
:text="phase === 'waiting' ? 'Turn on the switch' : 'Typing access'"
/>
<Text
v-if="phase === 'priming'"
type="body/m-reg"
class="setup__sub"
text="macOS asks permission before one app can type into another. EmojiCopy
needs it to put the emoji you click into whatever you were writing in."
/>
</template>
<PermissionStatusRow
v-if="phase !== 'priming'"
:name="isAllowed ? 'Allowed by macOS' : 'Typing access'"
:level="isAllowed ? 'allowed' : 'off'"
:state="isAllowed ? null : 'Not on yet'"
:action="isAllowed ? null : 'Open Accessibility Settings'"
@action="openSettings"
/>
<SettingsMap v-if="phase === 'waiting'" />
<Text
v-if="phase === 'priming'"
type="body/s-reg"
class="setup__scope"
text="EmojiCopy types only when you click an emoji. It never reads what you type."
/>
<Text
v-else-if="isAllowed"
type="body/s-reg"
class="setup__scope"
text="You only do this once. The last step checks that it actually types, which
is a different thing from being allowed."
/>
<template #cta>
<button v-if="phase === 'priming'" class="setup__cta" @click="requestAccess">
<Text type="body/m-heavy" tag="span" text="Continue" />
</button>
<button v-else-if="phase === 'waiting'" class="setup__cta setup__cta--wait" disabled>
<span class="spin" aria-hidden="true"></span>
<Text type="body/m-heavy" tag="span" text="Waiting for the switch" />
</button>
<button v-else class="setup__cta" @click="emit('next')">
<Text type="body/m-heavy" tag="span" text="Continue" />
</button>
</template>
<template #note>
<Text
v-if="phase === 'priming'"
type="body/s-reg"
class="setup__note"
text="macOS shows its own permission window next."
/>
<button v-else-if="phase === 'waiting'" class="setup__later" @click="emit('later')">
<Text type="body/s-med" tag="span" text="Set this up later" />
</button>
<Text
v-else
type="body/s-reg"
class="setup__note"
text="You can review this any time in Settings."
/>
</template>
</SetupWindow>
</template>
<script setup>
import Text from '@/components/Text.vue'
import Icon from '@/components/Icon.vue'
const props = defineProps({
name: { type: String, required: true },
// omit for a one-line row. Where the screen title already names the thing,
// repeating it in the row is the row saying nothing twice
state: { type: String, default: null },
// 'off' | 'allowed' | 'working'. Allowed means macOS says yes.
// Working means we watched an emoji land. They are not the same claim
level: { type: String, default: 'off' },
action: { type: String, default: null },
})
const emit = defineEmits(['action'])
</script>
<template>
<div
class="prow"
:class="{ 'prow--on': props.level !== 'off' }"
role="status"
:aria-label="props.state ? `${props.name}, ${props.state}` : props.name"
>
<span
class="prow__mark"
:class="`prow__mark--${props.level === 'working' ? 'on' : props.level}`"
aria-hidden="true"
>
<Icon v-if="props.level !== 'off'" name="check" weight="bold" />
</span>
<span v-if="props.state" class="prow__label">
<Text type="body/m-heavy" class="prow__name" :text="props.name" />
<Text type="body/s-reg" class="prow__state" :text="props.state" />
</span>
<Text v-else type="body/m-heavy" class="prow__name" :text="props.name" />
<button v-if="props.action" class="prow__go" @click="emit('action')">
<Text type="body/s-heavy" tag="span" :text="props.action" />
</button>
</div>
</template>
// apps/desktop/src-tauri/src/input.rs
//
// `request_accessibility_permission` already exists and PROMPTS. Polling it
// would re-raise the macOS window, so the poll needs a silent twin.
//
// WHICH PRIVILEGE. Posting a CGEvent is gated by kTCCServicePostEvent, not by
// kTCCServiceAccessibility. Both surface as the same row in Privacy & Security
// > Accessibility and the ordinary consumer path flips both with one switch,
// so the SCREEN is unaffected. The probe is not: CGPreflightPostEventAccess
// reads the row insertion actually depends on, AXIsProcessTrusted reads the
// neighbouring one. On a managed Mac the two are separately configurable and
// can disagree. Report both; treat post-event as authoritative.
#[cfg(target_os = "macos")]
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightPostEventAccess() -> bool;
fn CGRequestPostEventAccess() -> bool;
}
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
#[derive(serde::Serialize)]
pub struct AccessState {
pub can_post: bool,
pub ax_trusted: bool,
}
// silent. Safe to call on a timer
#[tauri::command]
pub async fn access_state() -> Result<AccessState, String> {
#[cfg(target_os = "macos")]
{
return Ok(AccessState {
can_post: unsafe { CGPreflightPostEventAccess() },
ax_trusted: unsafe { AXIsProcessTrusted() },
});
}
#[cfg(not(target_os = "macos"))]
{
Ok(AccessState { can_post: true, ax_trusted: true })
}
}
// VERIFY BEFORE SHIPPING: whether CGPreflightPostEventAccess updates within the
// life of the process or latches at first call. Reports differ and this screen
// assumes it updates. If it latches, poll AXIsProcessTrusted instead and let
// step 4's real insert be the confirmation.
// register alongside the existing handlers in lib.rs:
// .invoke_handler(tauri::generate_handler![
// start_oauth,
// insert_via_keystroke,
// request_accessibility_permission,
// access_state, // <- new
// open_mini_window_at_tray__command,
// close_mini_window
// ])
.prow {
width: 100%;
display: flex;
align-items: center;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--c-border-primary);
border-radius: var(--r-md);
text-align: left;
&.prow--on {
border-color: color-mix(in srgb, var(--green-600) 45%, var(--c-border-primary));
background: var(--green-300);
.prow__state { color: var(--green-800); }
}
/* shape, not colour. A green dot alone fails for the ~8% of men with a
colour-vision deficiency, and Apple calls this out twice: once under
Accessibility and once specifically about toggle state */
.prow__mark {
width: 21px;
height: 21px;
flex: none;
border-radius: 50%;
display: grid;
place-items: center;
&.prow__mark--off { border: 2px dashed var(--metal-300); }
&.prow__mark--allowed { border: 2px solid var(--green-700); color: var(--green-700); }
&.prow__mark--on { background: var(--green-700); color: var(--white); }
}
.prow__label {
display: flex;
flex-direction: column;
gap: 2px;
}
.prow__name { color: var(--metal-950); }
.prow__state { color: var(--metal-500); }
.prow__go {
margin-left: auto;
height: 28px;
padding: 0 12px;
border: 1px solid var(--c-border-secondary);
border-radius: var(--r-pill);
background: var(--white);
color: var(--metal-950);
cursor: pointer;
}
}
.sysmap {
width: 100%;
border: 1px solid var(--c-border-primary);
border-radius: var(--r-md);
overflow: hidden;
background: var(--metal-50);
text-align: left;
.sysmap__head {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 13px;
border-bottom: 1px solid var(--c-border-primary);
color: var(--metal-600);
}
.sysmap__crumb { color: var(--metal-400); }
.sysmap__lede { padding: 10px 13px 8px; color: var(--metal-600); }
.sysmap__list {
margin: 0 13px 13px;
border: 1px solid var(--c-border-primary);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--white);
}
.sysmap__row {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
border-bottom: 1px solid var(--c-border-primary);
color: var(--metal-800);
&:last-child { border-bottom: none; }
&.sysmap__row--us {
background: #FFF9DE;
box-shadow: inset 3px 0 0 var(--c-icon-yellow);
color: var(--metal-950);
}
}
/* macOS draws its own switch. Deliberately NOT `.toggle-input`, so nobody
reads this diagram as a control our window owns */
.sysmap__sw {
position: relative;
width: 28px;
height: 17px;
flex: none;
border-radius: 999px;
background: var(--metal-200);
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--white);
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
&.sysmap__sw--on {
background: #30D158;
&::after { left: 13px; }
}
}
}
.setup__cta--wait {
background: var(--metal-100);
color: var(--metal-500);
cursor: default;
}
.spin {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid var(--metal-300);
border-top-color: var(--metal-500);
animation: ec-spin .8s linear infinite;
}
@keyframes ec-spin {
to { transform: rotate(360deg); }
}
KeycapTester · new — the shortcut drawn as physical keys that light on keydown. It asks the human to confirm what the registration API can only write to a log.KeyCombo · ui/input/KeyCombo.vue — the existing recorder, used verbatim for rebinding. Already requires one modifier plus one ordinary key, blocks Alt and the reserved keys, and caps a combo at four.ShortcutBadge · ui/components/ShortcutBadge.vue — variant="combo", rendered by KeyCombo.SetupFlag · new — the amber advisory block. Not destructive: nothing is broken, the keys are simply already spoken for..keys row · keycaps and separators on one baseline, sized by content. Grid would force equal columns and the Space cap is wider than the rest (R10).<script setup>
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import Text from '@/components/Text.vue'
import KeyCombo from '@/input/KeyCombo.vue'
import KeycapTester from '@/components/setup/KeycapTester.vue'
import PermissionStatusRow from '@/components/setup/PermissionStatusRow.vue'
import SetupFlag from '@/components/setup/SetupFlag.vue'
const emit = defineEmits(['next', 'later'])
// chords macOS or Apple's own apps already answer to. Registering one of
// these SUCCEEDS and shadows the other binding system-wide, which is worse
// than failing, so it is opt-in and named rather than a silent default
const CLAIMED = {
'Meta+Control+Space': "Apple's own emoji picker",
'Control+KeyE': 'end of line in every text field',
}
const registered = ref(null)
const refused = ref([])
const confirmed = ref(false)
const rebinding = ref(false)
const combo = ref([
{ key: 'Meta', code: 'MetaLeft' },
{ key: 'Shift', code: 'ShiftLeft' },
{ key: 'E', code: 'KeyE' },
])
const collides = computed(() => CLAIMED[combo.value.map(k => k.code).join('+')] ?? null)
const applyCombo = async (next) => {
combo.value = next
rebinding.value = false
confirmed.value = false
// the rebind can fail too, and it says so in the same place
registered.value = await invoke('register_mini_shortcut', { combo: next })
}
onMounted(async () => {
const status = await invoke('mini_shortcut_status')
registered.value = status.registered
refused.value = status.refused
})
</script>
<template>
<SetupWindow :step="2" @later="emit('later')">
<template #head>
<Text tag="h2" class="setup__title" text="Your quick trigger" />
<Text
v-if="!collides"
type="body/m-reg"
class="setup__sub"
text="These keys open EmojiCopy on top of whatever you are doing. Press them
now, so we know macOS let us have them."
/>
</template>
<KeycapTester :combo="combo" @pressed="confirmed = true" />
<SetupFlag v-if="collides">
<Text
type="body/s-reg"
:text="`These keys already open ${collides}. EmojiCopy would take them over
everywhere on this Mac, and it would stop answering them.`"
/>
</SetupFlag>
<SetupFlag v-else-if="registered === false">
<Text
type="body/s-reg"
text="Another app on this Mac already holds these keys, so EmojiCopy did not
get them. Nothing would happen when you pressed them."
/>
</SetupFlag>
<PermissionStatusRow
v-else-if="confirmed"
name="Works everywhere"
level="working"
/>
<Text
v-else
type="body/s-reg"
class="setup__scope"
text="We suggest these three. They sit together on the bottom left of the keyboard."
/>
<KeyCombo v-if="rebinding" v-model="combo" @update:modelValue="applyCombo" />
<template #cta>
<div class="setup__row">
<button class="setup__cta setup__cta--ghost" @click="collides ? emit('next') : (rebinding = true)">
<Text
type="body/m-heavy"
tag="span"
:text="collides ? 'Take them anyway' : 'Pick different keys'"
/>
</button>
<button
class="setup__cta"
:class="{ 'setup__cta--wait': !confirmed && registered && !collides }"
:disabled="!confirmed && registered && !collides"
@click="collides ? applyCombo(SUGGESTED) : emit('next')"
>
<Text type="body/m-heavy" tag="span" :text="ctaLabel" />
</button>
</div>
</template>
<template #note>
<Text type="body/s-reg" class="setup__note"
text="You can change this any time in Settings." />
</template>
</SetupWindow>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
combo: { type: Array, required: true },
})
const emit = defineEmits(['pressed'])
const down = ref(new Set())
const GLYPH = {
Meta: '⌘',
Shift: '⇧',
Control: '⌃',
' ': 'Space',
}
const label = (k) => GLYPH[k.key] || (k.key.length === 1 ? k.key.toUpperCase() : k.key)
const onDown = (event) => {
down.value = new Set([...down.value, event.code])
if (props.combo.every(k => down.value.has(k.code))) emit('pressed')
}
const onUp = (event) => {
const next = new Set(down.value)
next.delete(event.code)
down.value = next
}
onMounted(() => {
window.addEventListener('keydown', onDown)
window.addEventListener('keyup', onUp)
})
onUnmounted(() => {
window.removeEventListener('keydown', onDown)
window.removeEventListener('keyup', onUp)
})
</script>
<template>
<div class="keys" role="group" aria-label="Quick trigger keys">
<template v-for="(k, i) in props.combo" :key="k.code">
<span class="keycap" :class="{ 'keycap--lit': down.has(k.code) }">
{{ label(k) }}
</span>
<span v-if="i < props.combo.length - 1" class="keys__plus">+</span>
</template>
</div>
</template>
// apps/desktop/src-tauri/src/lib.rs
//
// The registration loop already knows which chord took and which the OS
// refused. It prints both and returns neither. Keep them instead.
#[derive(Default, serde::Serialize, Clone)]
pub struct ShortcutStatus {
pub registered: Option<String>,
pub refused: Vec<String>,
}
pub static SHORTCUT_STATUS: Lazy<Mutex<ShortcutStatus>> =
Lazy::new(|| Mutex::new(ShortcutStatus::default()));
// inside setup(), replacing the current print-and-move-on loop:
for compact_shortcut in &compact_shortcuts {
match app.global_shortcut().register(compact_shortcut.clone()) {
Ok(_) => {
let mut status = SHORTCUT_STATUS.lock().unwrap();
status.registered = Some(compact_shortcut.to_string());
break;
}
Err(error) => {
// global-hotkey returns Error::FailedToRegister here. It is only
// "silent" because we discard it
println!("Could not register {}: {}", compact_shortcut, error);
SHORTCUT_STATUS
.lock()
.unwrap()
.refused
.push(compact_shortcut.to_string());
}
}
}
#[tauri::command]
pub fn mini_shortcut_status() -> ShortcutStatus {
SHORTCUT_STATUS.lock().unwrap().clone()
}
#[tauri::command]
pub fn register_mini_shortcut(
app: tauri::AppHandle,
combo: Vec<KeyPart>,
) -> Result<bool, String> {
// unregister the current chord, try the new one, report whether it took
}
.keys {
display: flex;
align-items: center;
gap: 8px;
.keys__plus { color: var(--metal-300); }
}
.keycap {
min-width: 46px;
height: 46px;
padding: 0 12px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--white);
border: 1px solid var(--c-border-primary);
box-shadow: 0 2px 0 var(--metal-200), 0 4px 12px rgba(15, 16, 18, .06);
color: var(--metal-800);
transition: transform .1s, box-shadow .1s, background .1s, color .1s;
/* travel is the point. The cap sinks by exactly the height of its own
shadow, so it reads as a key going down rather than a colour change */
&.keycap--lit {
background: var(--green-500);
border-color: var(--green-600);
color: var(--metal-950);
box-shadow: 0 1px 0 var(--green-700);
transform: translateY(1px);
}
}
.flag {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 10px 13px;
border-radius: var(--r-sm);
background: #FFF9DE;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c-icon-yellow) 55%, transparent);
text-align: left;
color: #6B5300;
b { color: #4A3900; }
}
ToggleSwitch · ui/input/ToggleSwitch.vue — size="sm", all three rows. Unmodified.setting-row · ui/components/AllSettings.vue — the same row markup the Settings modal uses, so these are literally the controls the user meets again later.HomePreview · new — a live menu bar and Dock strip that answer the toggles under them. The screen's whole argument: you see where the app goes instead of reading a label about it..setting-row row · label column takes the slack (flex: 1 0 0), control keeps its intrinsic size. Straight from AllSettings.vue (R10).<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import Text from '@/components/Text.vue'
import ToggleSwitch from '@/input/ToggleSwitch.vue'
import HomePreview from '@/components/setup/HomePreview.vue'
const props = defineProps({
// step 2 hands this down. Turning off the Dock icon with no menu bar icon
// and no working trigger would leave no way into the app
triggerWorks: { type: Boolean, default: false },
})
const emit = defineEmits(['next', 'later'])
const menuBar = ref(1)
const dockIcon = ref(1)
const openAtLogin = ref(0)
const canHideDock = computed(() => Boolean(menuBar.value) && props.triggerWorks)
// the two icons guard each other. Turning off the second one is refused,
// quietly, and the row says why rather than the switch just not moving
watch(menuBar, (on) => {
if (!on && !dockIcon.value) dockIcon.value = 1
})
watch(dockIcon, (on) => {
if (!on && !menuBar.value) menuBar.value = 1
})
watch(menuBar, (v) => invoke('set_menu_bar_icon', { shown: Boolean(v) }))
watch(dockIcon, (v) => invoke('set_dock_icon', { shown: Boolean(v) }))
watch(openAtLogin, (v) => invoke('set_open_at_login', { enabled: Boolean(v) }))
// never render a switch that lies. Read the real login-item state back
onMounted(async () => {
const status = await invoke('open_at_login_status')
if (status.readable) openAtLogin.value = status.enabled ? 1 : 0
})
</script>
<template>
<SetupWindow :step="3" @later="emit('later')">
<template #head>
<Text tag="h2" class="setup__title" text="Where it lives" />
<Text
type="body/m-reg"
class="setup__sub"
text="Closing the window does not quit EmojiCopy. It keeps running so your
trigger still works. Pick what you want to see."
/>
</template>
<HomePreview :menu-bar="Boolean(menuBar)" :dock="Boolean(dockIcon)" />
<div class="setting-group">
<div class="setting-row">
<div class="start">
<Text class="setting-name" tag="i" type="body/m-heavy"
text="Keep the menu bar icon" />
<Text class="setting-description" type="body/m-reg"
text="Click it to open EmojiCopy right under it." />
</div>
<div class="end">
<ToggleSwitch v-model="menuBar" size="sm" />
</div>
</div>
<div class="setting-row" :class="{ 'setting-row--off': !canHideDock }">
<div class="start">
<Text class="setting-name" tag="i" type="body/m-heavy"
text="Show the Dock icon" />
<Text
class="setting-description"
type="body/m-reg"
:text="canHideDock
? 'Turn this off and EmojiCopy stays out of the Dock and out of Command Tab. Your trigger and the menu bar icon still open it.'
: 'Press your trigger on the last step first, so you keep a way to open EmojiCopy without the Dock.'"
/>
</div>
<div class="end">
<ToggleSwitch v-model="dockIcon" size="sm" :disabled="!canHideDock" />
</div>
</div>
<div class="setting-row">
<div class="start">
<Text class="setting-name" tag="i" type="body/m-heavy"
text="Open at Login" />
<Text class="setting-description" type="body/m-reg"
text="EmojiCopy is ready the moment you sign in. macOS will confirm this itself." />
</div>
<div class="end">
<ToggleSwitch v-model="openAtLogin" size="sm" />
</div>
</div>
</div>
<template #cta>
<button class="setup__cta" @click="emit('next')">
<Text type="body/m-heavy" tag="span" text="Continue" />
</button>
</template>
<template #note>
<Text type="body/s-reg" class="setup__note"
text="All three live in Settings afterwards, under these names." />
</template>
</SetupWindow>
</template>
// apps/desktop/src-tauri/src/lib.rs
// --- Dock icon --------------------------------------------------------------
// Tauri 2.5 added AppHandle::set_dock_visibility, which sits right next to
// set_activation_policy and is the purpose-built call for this switch. NOT
// LSUIElement: that is baked at build time and would need a relaunch to take.
//
// It is debounced by roughly a second underneath and gives no completion
// callback, so the switch must reflect INTENT and tolerate the lag rather than
// animate as though the change were instant.
#[tauri::command]
pub fn set_dock_icon(app: tauri::AppHandle, shown: bool) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
app.set_dock_visibility(shown).map_err(|e| e.to_string())?;
// coming back from hidden leaves the app's own menu bar inert unless we
// re-activate. Known AppKit behaviour, not a Tauri bug
if shown {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}
}
Ok(())
}
// SHIP THIS BEFORE THE DOCK SWITCH. With the Dock icon hidden, launching the
// app again from Spotlight or Finder activates the running instance and shows
// NOTHING, because there is no reopen handler. That is what makes the switch
// feel like a lockout.
// .build(...)
// .run(|app, event| {
// if let tauri::RunEvent::Reopen { .. } = event {
// show_main_window(app);
// }
// })
// --- Menu bar icon ----------------------------------------------------------
#[tauri::command]
pub fn set_menu_bar_icon(app: tauri::AppHandle, shown: bool) -> Result<(), String> {
if let Some(tray) = app.tray_by_id("main") {
tray.set_visible(shown).map_err(|e| e.to_string())?;
}
Ok(())
}
// --- Open at Login ----------------------------------------------------------
// SMAppService.mainApp via tauri-plugin-autostart. Keep the LaunchAgent mode:
// the AppleScript mode raises an Automation permission dialog, and this app's
// Info.plist carries no NSAppleEventsUsageDescription to answer it with.
//
// SMAppService is @available(macOS 13). The binary's real floor is Big Sur, so
// either a fallback ships or the declared minimum moves to 13.
#[tauri::command]
pub fn set_open_at_login(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
use tauri_plugin_autostart::ManagerExt;
let manager = app.autolaunch();
if enabled {
manager.enable().map_err(|e| e.to_string())
} else {
manager.disable().map_err(|e| e.to_string())
}
}
// read the real state back every time the pane appears, so a user who turned
// this off in System Settings does not meet a toggle that lies. If it cannot be
// read reliably, HIDE the row rather than render it wrong
#[tauri::command]
pub fn open_at_login_status(app: tauri::AppHandle) -> LoginItemStatus {
// { readable: bool, enabled: bool }
}
/* a real menu bar and Dock at real proportions, not an illustration. The
icons that appear and disappear are the app's own */
.homeprev {
width: 100%;
border-radius: var(--r-md);
overflow: hidden;
background: linear-gradient(148deg, #37456B 0%, #6B5C88 52%, #2E3B58 100%);
padding-bottom: 9px;
.homeprev__dock {
margin: 30px auto 0;
width: max-content;
display: flex;
align-items: flex-end;
gap: 5px;
padding: 4px 6px;
border-radius: 12px;
background: rgba(255, 255, 255, .20);
box-shadow: inset 0 0 0 .5px rgba(255, 255, 255, .30);
}
}
/* both icons animate out rather than blink, so the eye follows the thing that
moved and the user learns which icon the row controls. Under
prefers-reduced-motion they cut straight to the end state */
.mac__tray {
transition: opacity .2s, transform .2s;
&.mac__tray--off {
opacity: 0;
transform: scale(.7);
}
}
.mac__dock-app {
transition: opacity .2s, width .2s, margin .2s;
&.mac__dock-app--off {
opacity: 0;
width: 0;
margin-left: -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.mac__tray,
.mac__dock-app { transition: none; }
}
.setting-group {
width: 100%;
padding: 4px 18px;
border: 1px solid var(--c-border-primary);
border-radius: var(--r-md);
.setting-row + .setting-row {
border-top: 1px solid var(--c-border-primary);
}
}
/* the disabled row stays readable. It is explaining a prerequisite, not
refusing an action */
.setting-row--off {
.setting-name,
.setting-description {
color: var(--metal-400);
}
}
SetupProofField · new — a real focused text field inside the setup window that the app inserts into for real. Nothing here is simulated.MiniWindow · apps/desktop/src-tauri/src/window.rs — the shipping 405 × 335 popover with its directional beak, opened at the cursor. Unmodified. Card E draws it at its real point size and its real anchor offset (top-left pointer, anchor x = 104).PermissionStatusRow · card B — the same row, now at level="working". This is the only screen that can earn that level..setup__col column · one gap governs every sibling. No margins on children (R11).<script setup>
import { ref, onMounted } from 'vue'
import Text from '@/components/Text.vue'
import KeycapTester from '@/components/setup/KeycapTester.vue'
import PermissionStatusRow from '@/components/setup/PermissionStatusRow.vue'
const props = defineProps({
combo: { type: Array, required: true },
})
const emit = defineEmits(['done'])
const field = ref(null)
const landed = ref(false)
// the emoji arrives as a real keystroke from insert_via_keystroke, exactly the
// way it will in any other app. Nothing here is faked, which is the point:
// this is the only end-to-end proof that the privilege, the shortcut and the
// focus handoff all work TOGETHER. Each can be individually green while the
// chain is broken
const onInput = () => {
if (/\p{Extended_Pictographic}/u.test(field.value.value)) landed.value = true
}
onMounted(() => field.value?.focus())
</script>
<template>
<SetupWindow :step="4">
<template #head>
<Text tag="h2" class="setup__title" text="Try it" />
<Text
v-if="!landed"
type="body/m-reg"
class="setup__sub"
text="Click in the box, press your keys, and pick anything. It lands where
the cursor is."
/>
</template>
<input
ref="field"
class="setup__proof"
type="text"
placeholder="Type something here"
aria-label="Test field"
@input="onInput"
>
<KeycapTester v-if="!landed" :combo="props.combo" />
<PermissionStatusRow
v-else
name="Working. Same in every app."
level="working"
/>
<Text
v-if="landed"
type="body/s-reg"
class="setup__scope"
text="EmojiCopy is in your menu bar whenever you want it."
/>
<template #cta>
<button
class="setup__cta"
:class="{ 'setup__cta--ghost': !landed }"
@click="emit('done')"
>
<Text
type="body/m-heavy"
tag="span"
:text="landed ? 'Start using EmojiCopy' : 'Skip the test'"
/>
</button>
</template>
<template #note>
<Text
type="body/s-reg"
class="setup__note"
:text="landed
? 'Settings lives under Command Comma.'
: 'This is the whole app.'"
/>
</template>
</SetupWindow>
</template>
.setup__proof {
width: 100%;
min-height: 52px;
padding: 13px 14px;
border: 1px solid var(--c-border-primary);
border-radius: 10px;
background: var(--white);
box-shadow: 0 6px 18px rgba(15, 16, 18, .08);
color: var(--metal-800);
font-family: var(--ff-inter);
&::placeholder {
color: var(--c-text-placeholder);
}
/* focused on mount and kept focused. If it loses focus the insert lands
somewhere else and the step silently lies about working */
&:focus {
outline: none;
border-color: var(--green-600);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--green-500) 40%, transparent);
}
}
SetupWindow · card A — rendered with no step, so no dots. This is a guard, not a step.SetupArtMove · new — a flat line drawing, one accent, no fills. Two folder marks and the real app icon arcing between them on a dashed path. Never a screenshot of Finder..setup__stack column · two stacked buttons at the same width. Stacked rather than side by side because the secondary is a lesser choice, not a peer (R10).<script setup>
import { invoke } from '@tauri-apps/api/core'
import Text from '@/components/Text.vue'
import SetupArtMove from '@/components/setup/SetupArtMove.vue'
const emit = defineEmits(['continue'])
const moveAndReopen = () => invoke('move_to_applications')
</script>
<template>
<SetupWindow>
<template #art><SetupArtMove /></template>
<template #head>
<Text tag="h2" class="setup__title" text="Move to Applications" />
<Text
type="body/m-reg"
class="setup__sub"
text="EmojiCopy is running from a temporary copy of itself. macOS forgets
any permission you give to a copy like this one."
/>
</template>
<template #cta>
<div class="setup__stack">
<button class="setup__cta" @click="moveAndReopen">
<Text type="body/m-heavy" tag="span" text="Move and reopen" />
</button>
<button class="setup__cta setup__cta--ghost" @click="emit('continue')">
<Text type="body/m-heavy" tag="span" text="Continue anyway" />
</button>
</div>
</template>
<template #note>
<Text type="body/s-reg" class="setup__note"
text="It takes a second and nothing is lost." />
</template>
</SetupWindow>
</template>
// apps/desktop/src-tauri/src/lib.rs
//
// Runs at launch, BEFORE the flow renders and before anything asks for a
// permission. A grant made by a translocated copy attaches to a randomised
// read-only path that will not exist next launch, so asking there spends the
// one-shot macOS window on a bundle that is about to disappear.
#[tauri::command]
pub fn needs_relocation(app: tauri::AppHandle) -> bool {
let path = std::env::current_exe().unwrap_or_default();
let path = path.to_string_lossy();
let translocated = path.contains("/AppTranslocation/");
let loose = ["/Downloads/", "/Desktop/"]
.iter()
.any(|dir| path.contains(dir));
translocated || loose
}
#[tauri::command]
pub fn move_to_applications(app: tauri::AppHandle) -> Result<(), String> {
// copy the bundle to /Applications, relaunch from there, exit this copy.
// If the destination is not writable or a name already exists, surface the
// failure state rather than half-moving
}
MiniNudge · new — a compact block inside the 405pt mini window. Every illustrated ask in this category is wider than 405pt, so the mini gets a title, a line and one button, and hands off to the main window.SettingsMap · card B — the same component with different props. lede, pointer, switchOn and showMinus change; nothing else does.SetupWindow · card A — no step, so no dots. This is not a step in a flow..setup__row row · two peer buttons at intrinsic width. The recovery action is not more important than the escape hatch (R10).<script setup>
import { ref, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import Text from '@/components/Text.vue'
import SettingsMap from '@/components/setup/SettingsMap.vue'
const ACCESSIBILITY_PANE =
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
// 'revoked' -> the privilege is genuinely off. Grant it again the normal way
// 'stale' -> macOS says yes and the probe fails. Different instruction
const kind = ref('revoked')
onMounted(async () => {
const health = await invoke('accessibility_health')
kind.value = health.trusted && !health.probe_ok ? 'stale' : 'revoked'
})
const openSettings = () => openUrl(ACCESSIBILITY_PANE)
const relaunch = () => invoke('quit_and_reopen')
</script>
<template>
<SetupWindow>
<template #head>
<Text
tag="h2"
class="setup__title"
:text="kind === 'stale' ? 'Not typing' : 'Typing access off'"
/>
<Text
type="body/m-reg"
class="setup__sub"
:text="kind === 'stale'
? 'macOS still lists EmojiCopy as allowed and nothing lands. An update usually changes what macOS thinks EmojiCopy is.'
: 'macOS is not letting EmojiCopy type into other apps.'"
/>
</template>
<SettingsMap
:lede="kind === 'stale'
? 'Select EmojiCopy in the list, then click the minus button under it. EmojiCopy will ask again next time you use it.'
: 'Allow the applications below to control your computer.'"
:pointer="kind === 'stale'
? 'Turning the switch off and back on does not clear this. It has to be removed.'
: 'EmojiCopy is already in the list, switched off. You may need to scroll to reach it.'"
:switch-on="kind === 'stale'"
:show-minus="kind === 'stale'"
/>
<Text
type="body/s-reg"
class="setup__scope"
text="Until then, clicking an emoji copies it to your clipboard."
/>
<template #cta>
<div class="setup__row">
<button v-if="kind === 'stale'" class="setup__cta setup__cta--ghost" @click="relaunch">
<Text type="body/m-heavy" tag="span" text="Quit and reopen" />
</button>
<button class="setup__cta" @click="openSettings">
<Text type="body/m-heavy" tag="span" text="Open Accessibility Settings" />
</button>
</div>
</template>
<template #note>
<Text
type="body/s-reg"
class="setup__note"
text="Everything else keeps working. Only typing into other apps is paused."
/>
</template>
</SetupWindow>
</template>
// apps/desktop/src-tauri/src/input.rs
//
// A permission read alone is not enough. After some updates the privilege
// reads as granted while every call still fails. Pair the read with a real
// probe: that boolean pair is what separates 'never granted' from 'granted but
// broken', and the two need different instructions.
#[derive(serde::Serialize)]
pub struct AccessibilityHealth {
pub trusted: bool,
pub probe_ok: bool,
}
#[tauri::command]
pub async fn accessibility_health() -> Result<AccessibilityHealth, String> {
#[cfg(target_os = "macos")]
{
let trusted = unsafe { CGPreflightPostEventAccess() };
// a real event round trip, not another preflight. Succeeds only if
// posting genuinely works for this binary right now
let probe_ok = trusted && post_noop_event().is_ok();
return Ok(AccessibilityHealth { trusted, probe_ok });
}
#[cfg(not(target_os = "macos"))]
{
Ok(AccessibilityHealth { trusted: true, probe_ok: true })
}
}
// Run this ONCE on the first launch after any version change, before the user
// tries anything. A failure then becomes an expected, explained event instead
// of a mystery mid-sentence three days later.
//
// OPEN: who fires it. If updates arrive through an in-app updater, the check
// hooks the relaunch. If they arrive by re-downloading a DMG, this check and
// the translocation guard in card F are the same event. Different builds.