A plain label/value page: version, revision, OS, JavaScript engine, user agent, paths. Help › About Google Chrome shows only the version; the full page is an address support tells you to type.
support.google.com/chrome ↗Where the device details live so we can send someone to them while we're troubleshooting, and what they see once they get there. Then how other apps do it.
shared-routes.js · ui/utils/router/shared-routes.js — one route added next to /apps and /contact; nothing in the app links to it<Layout> — the same page shell /apps, /contact and /dev use<Text> — title body/xxl, sub-line body/m-reg, rows exactly as in AllSettings.vueAllSettings.vue — the 4th group is deleted; Settings ends after DisplayDeviceInfo.vue · new page ui/pages/DeviceInfo.vue — title, version line, one settings-style card holding <DeviceInfoList>useDeviceInfo() · ui/utils/device-info.js — the collector that lives in AllSettings.vue today, lifted into a composable so a page, a modal or a footer can all render it (source below)<DeviceInfoList> — the list itself, specced in section 2 (look 1 or 2).device-info-page column, max-width 640, centered · rows are the existing .setting-row flex (R10)<script setup>
import Layout from '@/layouts/Main.vue'
import Text from '@/components/Text.vue'
import { useDeviceInfo } from '@/utils/device-info'
const { rows } = useDeviceInfo()
</script>
<template>
<Layout>
<div class="device-info-page">
<div class="device-info-page__head">
<Text type="body/xxl">Device info</Text>
<Text type="body/m-reg">EmojiCopy {{ APP_VERSION }}</Text>
</div>
<div class="settings-modal-group">
<DeviceInfoList :rows="rows" /> <!-- the list from section 2 (look 1 or 2) -->
</div>
</div>
</Layout>
</template>
import { ref, onMounted, onUnmounted } from 'vue'
export function useDeviceInfo () {
const rows = ref([])
const collect = async () => {
// ...the exact collector that lives in AllSettings.vue today,
// unchanged, writing the 20 { name, description, value } rows
// (+ a `group` key for look 2: 'sys' | 'screen' | 'input' | 'hw')
}
onMounted(() => {
collect()
window.addEventListener('resize', collect)
window.visualViewport?.addEventListener('resize', collect)
window.screen.orientation?.addEventListener('change', collect)
})
onUnmounted(() => {
window.removeEventListener('resize', collect)
window.visualViewport?.removeEventListener('resize', collect)
window.screen.orientation?.removeEventListener('change', collect)
})
return { rows }
}
const DeviceInfo = () => import('@/pages/DeviceInfo.vue')
// ...
{ path: '/device-info', component: DeviceInfo, name: 'device-info' },
.device-info-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 640px;
margin: 0 auto;
padding: 40px 24px 64px;
.device-info-page__head {
display: flex;
flex-direction: column;
gap: 4px;
}
}
AllSettings.vue — the 4th group and its Show/Hide header are deleted; one version-line button after the groups takes over as the trigger<Text type="body/s-reg" color="text-tertiary"> — the line. Version only, no summary, no caretuseDeviceInfo() + <DeviceInfoList> — what the click reveals (section 2; source under A).settings-modal-foot row, centered (R10)<script setup>
const showDeviceInfo = ref(false)
</script>
<!-- template: last children of .settings-modal-contents -->
<button class="settings-modal-foot" type="button" :aria-expanded="showDeviceInfo" @click="showDeviceInfo = !showDeviceInfo">
<Text type="body/s-reg" color="text-tertiary">EmojiCopy {{ APP_VERSION }}</Text>
</button>
<!-- what opens: pick one from section 2 —
look 1/2: a group under the line → <div class="settings-modal-group" v-if="showDeviceInfo"><DeviceInfoList /></div>
look 3: the sub-view → view = 'device-info'
look 4: its own modal → deviceInfoPopped = true -->
.settings-modal-foot {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 8px 0 0;
border: 0;
background: none;
cursor: pointer;
}
AllSettings.vue — the collector stays; the group renders behind one flag, in whichever look section 2 picks<SettingsModal> · ui/modals/SettingsModal.vue — unchanged shell; the gesture wants a one-line @header-click emit on ModalHeaderAllSettings so it lands on the titlerevealDeviceInfo — one ref, one handler: ⌥/Alt-click on the title, or 5 taps within 3s on touch, or ?devinfo=1 in the URL<script setup>
const revealDeviceInfo = ref(new URLSearchParams(location.search).get('devinfo') === '1')
// ⌥-click the title (desktop) or 5 taps in 3s (touch)
let taps = 0, tapTimer = null
const onHeaderClick = e => {
if (e.altKey) { revealDeviceInfo.value = !revealDeviceInfo.value; return }
taps += 1
clearTimeout(tapTimer)
tapTimer = setTimeout(() => { taps = 0 }, 3000)
if (taps >= 5) { taps = 0; revealDeviceInfo.value = true }
}
</script>
<template>
<SettingsModal ... @header-click="onHeaderClick">
<div class="settings-modal-contents">
<!-- ...Quick Settings / Behavior / Display unchanged... -->
<!-- the list, gated (look 1/2 shown; look 3/4 = set view / pop the modal instead) -->
<div class="settings-modal-group" v-if="revealDeviceInfo">
<Text tag="i" type="body/l-heavy">Device info</Text>
<DeviceInfoList :rows="rows" />
</div>
</div>
</SettingsModal>
</template>
<!-- ModalHeaderAllSettings.vue: add @click="$emit('header-click', $event)"
on the title's wrapper and re-emit it through SettingsModal (one
line each). -->
/* no additions — the list brings its own (section 2) */
AllSettings.vue — the 4th group becomes "About": a Version row and one drill row. What the row opens is section 2's look 3 (sub-view) or 4 (own modal)<Icon name="arrow-caret-right" /> — the row's chevron (the caret the menus use).setting-drill — a whole-row button: name + caret. Reusable for any future sub-page in Settings.setting-drill row · start column grows, caret fixed (R10)<script setup>
import Icon from '@/components/Icon.vue'
const openDeviceInfo = () => { view.value = 'device-info' } // look 3 — or deviceInfoPopped.value = true for look 4
</script>
<!-- template: 4th group, replaces Device Info -->
<div class="settings-modal-group">
<Text tag="i" type="body/l-heavy">About</Text>
<div class="settings-internal-group">
<div class="setting-row">
<div class="start">
<Text class="setting-name" tag="i" type="body/m-heavy">Version</Text>
</div>
<div class="end">
<Text type="body/m-med">EmojiCopy {{ APP_VERSION }}</Text>
</div>
</div>
<button class="setting-drill" type="button" @click="openDeviceInfo">
<div class="start">
<Text class="setting-name" tag="i" type="body/m-heavy">Device info</Text>
</div>
<Icon name="arrow-caret-right" />
</button>
</div>
</div>
.settings-internal-group {
.setting-drill {
display: flex;
align-items: center;
gap: 16px;
align-self: stretch;
padding: 8px 0;
border: 0;
background: none;
text-align: left;
cursor: pointer;
.start { display: flex; flex-direction: column; gap: 4px; flex: 1 0 0; }
.icon { font-size: 2rem; color: var(--c-text-tertiary); }
&:hover .icon { color: var(--c-text-primary); }
}
}
Contact.vue · ui/pages/Contact.vue — ships today as a stub (<h1>Contact</h1>) at /contact; this fills it<Layout> — the page shell the other routes use<Text> — title body/xxl, sub-line body/m-reg, group title body/l-heavy, rows as in AllSettings.vueAllSettings.vue — the 4th group is deleted; Settings ends after DisplayuseDeviceInfo() + <DeviceInfoList> — the list (section 2; composable source under A).contact-page column, max-width 640, centered · rows are the existing .setting-row flex (R10)<script setup>
import Layout from '@/layouts/Main.vue'
import Text from '@/components/Text.vue'
import { useDeviceInfo } from '@/utils/device-info'
const { rows } = useDeviceInfo()
</script>
<template>
<Layout>
<div class="contact-page">
<div class="contact-page__head">
<Text type="body/xxl">Contact</Text>
<Text type="body/m-reg">Write to hello@emojicopy.com. Include a screenshot of the details below if something isn't working.</Text>
</div>
<div class="settings-modal-group">
<Text tag="i" type="body/l-heavy">Device info</Text>
<DeviceInfoList :rows="rows" /> <!-- the list from section 2 (look 1 or 2) -->
</div>
</div>
</Layout>
</template>
.contact-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 640px;
margin: 0 auto;
padding: 40px 24px 64px;
.contact-page__head {
display: flex;
flex-direction: column;
gap: 4px;
}
}
<Text> — name body/m-reg secondary, value body/m-med primary; group title body/l-heavy.settings-modal-group — the same card the other groups use; this sits inside it (or inside a page card for A / E)<DeviceInfoList> · new ui/components/DeviceInfoList.vue — a <dl> grid: 20 name/value pairs, no descriptions.device-info-list two columns, max-content + 1fr · names never wrap, values right-align and wrap (R10)<!-- ui/components/DeviceInfoList.vue — the one component every place renders -->
<script setup>
import Text from '@/components/Text.vue'
const props = defineProps(['rows'])
</script>
<template>
<dl class="device-info-list">
<template v-for="row in props.rows" :key="row.name">
<Text tag="dt" type="body/m-reg" color="text-secondary">{{ row.name }}</Text>
<Text tag="dd" type="body/m-med">{{ row.value }}</Text>
</template>
</dl>
</template>
<!-- in AllSettings.vue (B / C) — under the trigger: -->
<div class="settings-modal-group" v-if="showDeviceInfo">
<Text tag="i" type="body/l-heavy">Device info</Text>
<DeviceInfoList :rows="rows" />
</div>
.device-info-list {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 24px;
align-items: baseline;
align-self: stretch;
margin: 0;
dd {
margin: 0;
text-align: right;
overflow-wrap: anywhere;
color: var(--c-text-primary);
}
}
<Text type="body/s-heavy" color="text-tertiary"> — the four headers, uppercase via CSS<DeviceInfoList grouped /> — same component, one prop; rows carry a group key from the composable<script setup>
import { computed } from 'vue'
import Text from '@/components/Text.vue'
const props = defineProps({ rows: Array, grouped: Boolean })
const GROUPS = [
['sys', 'Browser & system'], // Browser · Operating system · Architecture · Model · Raw user agent
['screen', 'Screen'], // Viewport · Visual viewport · Screen · Available screen · DPR · Color depth · Orientation
['input', 'Input'], // Touch points · Coarse pointer · Fine pointer · Hover available
['hw', 'Locale & hardware'], // CPU cores · Device memory · Language · Timezone
]
const sections = computed(() => GROUPS.map(([key, label]) => ({ key, label, rows: props.rows.filter(r => r.group === key) })))
</script>
<template>
<dl class="device-info-list">
<template v-if="props.grouped" v-for="s in sections" :key="s.key">
<Text tag="dt" type="body/s-heavy" color="text-tertiary" class="device-info-list__head">{{ s.label }}</Text>
<template v-for="row in s.rows" :key="row.name">
<Text tag="dt" type="body/m-reg" color="text-secondary">{{ row.name }}</Text>
<Text tag="dd" type="body/m-med">{{ row.value }}</Text>
</template>
</template>
<template v-else v-for="row in props.rows" :key="row.name">
<Text tag="dt" type="body/m-reg" color="text-secondary">{{ row.name }}</Text>
<Text tag="dd" type="body/m-med">{{ row.value }}</Text>
</template>
</dl>
</template>
.device-info-list {
.device-info-list__head {
grid-column: 1 / -1;
padding-top: 12px;
text-transform: uppercase;
letter-spacing: .06em;
}
.device-info-list__head:first-child { padding-top: 0; }
}
<SettingsModal> · ui/modals/SettingsModal.vue — same shell; title/subtitle props switch with a view ref<TextButton variant="menu-back" iconStart="arrow-left" label="Settings" /> — the app's own back affordance<DeviceInfoList> — look 1 (or 2) inside one group card.settings-modal-contents column, unchanged (R10)<script setup>
import TextButton from '@/buttons/TextButton.vue'
import DeviceInfoList from '@/components/DeviceInfoList.vue'
import { useDeviceInfo } from '@/utils/device-info'
const view = ref('settings') // 'settings' | 'device-info'
const { rows } = useDeviceInfo()
watch(() => doShow.value, open => { if (!open) view.value = 'settings' }) // reopen on the main view
</script>
<template>
<SettingsModal
v-model:popped="doShow"
:background-class="'settings-modal-bg'"
width="674px"
scroll="content"
:title="view === 'settings' ? 'Settings' : 'Device info'"
:subtitle="view === 'settings' ? 'Manage your preferences and customize your experience.' : ''"
>
<div class="settings-modal-contents" v-if="view === 'settings'">
<!-- ...the groups; the trigger (B line / C flag / D row) sets view = 'device-info' -->
</div>
<div class="settings-modal-contents" v-else>
<TextButton variant="menu-back" iconStart="arrow-left" label="Settings" @click="view = 'settings'" />
<div class="settings-modal-group">
<DeviceInfoList :rows="rows" />
</div>
</div>
</SettingsModal>
</template>
/* the sub-view reuses .settings-modal-contents / .settings-modal-group as-is */
<SettingsModal> · ui/modals/SettingsModal.vue — reused as the shell at width="520px", title="Device info", no subtitlePopModal layering — opening it from Settings stacks over the Settings modal, same as any modal-from-modal today<DeviceInfoModal> · new ui/modals/DeviceInfoModal.vue — the shell + one group + <DeviceInfoList>; v-model:popped.settings-modal-contents column, one group (R10)<!-- ui/modals/DeviceInfoModal.vue -->
<script setup>
import SettingsModal from '@/modals/SettingsModal.vue'
import DeviceInfoList from '@/components/DeviceInfoList.vue'
import { useDeviceInfo } from '@/utils/device-info'
const props = defineProps(['popped'])
const emit = defineEmits(['update:popped'])
const { rows } = useDeviceInfo()
</script>
<template>
<SettingsModal
:popped="props.popped"
@update:popped="v => emit('update:popped', v)"
:background-class="'settings-modal-bg'"
width="520px"
scroll="content"
title="Device info"
>
<div class="settings-modal-contents">
<div class="settings-modal-group">
<DeviceInfoList :rows="rows" />
</div>
</div>
</SettingsModal>
</template>
<!-- any trigger (B line, C gesture, D row, a support link) — in Main.vue: -->
<DeviceInfoModal v-model:popped="deviceInfoPopped" />
/* the modal reuses .settings-modal-bg / -contents / -group as-is */
A plain label/value page: version, revision, OS, JavaScript engine, user agent, paths. Help › About Google Chrome shows only the version; the full page is an address support tells you to type.
support.google.com/chrome ↗A long read-only page of tables, reached from Help or by typing the address, never from Settings. Support articles link straight to it.
support.mozilla.org ↗No device-info surface in the app at all. Support asks people to open their browser's own about-page and paste it. What web apps do when they haven't built A.
help.miro.com ↗A passive text block under the last settings tab: release channel, build, host version, OS. Not a group, not a page. Support says "scroll to the bottom of Settings and send me what it says."
support.discord.com ↗The version is plain footer text under Settings. Press it the right way and a debug menu opens. Ordinary users only ever see the version line.
core.telegram.org ↗About phone is a plain read-only list. The developer surface underneath is hidden until a gesture unlocks it, with a countdown so you know it's working. Chrome for Android does the same on its "Application version" row.
developer.android.com ↗A browser extension with nothing diagnostic in its normal popup. A key-chord click reveals a Debug Menu, "typically requested by our Support team." The closest comp to our extension build.
support.grammarly.com ↗One scrolling read-only list two levels deep: version, model, serial, storage, network identifiers. The top level of Settings never shows a single one of these. Android's About phone is the same shape.
support.apple.com ↗The one comp that keeps live diagnostics as a Settings tab: CPU, memory, latency, frame rate, with a "Report problem" form beside it. Earns its place because the numbers change mid-call. Ours don't.
support.zoom.com ↗The user writes a summary and sends. Per Slack: "Your logs will automatically be attached when you send your message." Nothing diagnostic is ever displayed in Preferences.
slack.com/help ↗A "Send system information" checkbox, on by default; the linked words open a page showing what rides along. Same shape as Gmail's and Maps' Send feedback.
support.google.com/chrome ↗The chat widget collects device type, OS and browser and shows them to the agent beside the conversation. The customer never opens anything. Intercom's Messenger works the same way; we have email, not a widget.
support.zendesk.com ↗