Kiosk: find a free drive by typing, and use the width of the panel
Some checks failed
CI / naming (push) Successful in 2m13s
CI / frontend (push) Successful in 1m19s
CI / migrations-mysql (push) Failing after 6m11s
CI / backend (push) Has been cancelled

A cabinet of thirty drives was one long column to read through. The free list
now filters from a search box and lays out in as many columns as the panel is
wide - four on a wall display, two on a tablet, one on a phone - while the
steps that hold a single control stay narrow, because stretching one input
across a 24 inch screen reads as a broken page.

A drive is found by its id, by what is written on it, or by where it lives. The
words can arrive in any order, and "locker" is part of what is searched, so
"locker engineer" finds the engineering locker - that is how people say it out
loud. Filtering is client-side: every free drive is already on the screen, and a
round trip per keystroke on a wall panel would be worse than no filter at all.
The matcher lives in its own module with tests rather than inside the view.

The box takes focus as the picker opens so somebody can simply start typing,
and it is cleared when the kiosk resets - the next person at the cabinet must
not inherit a filter and conclude there are no drives left.

Also: both this view and the guides blocks coloured muted text with a
`--textmuted` variable this app has never defined. The browser dropped the
declaration, so every hint, subtitle and locker line was rendering at full
strength. The variable is `--text-light`.
This commit is contained in:
cproudlock
2026-08-27 10:35:11 -04:00
parent 11f39253c2
commit 605dd6cb1b
5 changed files with 190 additions and 13 deletions

View File

@@ -253,7 +253,7 @@ function select (block) {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imageloading { color: var(--text-light); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }

View File

@@ -0,0 +1,29 @@
// Narrowing a cabinet of drives from a kiosk keyboard.
//
// Every free drive is already on the client, so this filters what is on screen
// rather than asking the API again - the list is small, and a round trip per
// keystroke on a wall panel is a worse experience than no filter at all.
// What one drive can be found by. 'locker' is in here on purpose: it is how
// people say where a drive lives, so "locker engineer" has to find the drives
// in the engineering locker rather than nothing at all.
export function drivetext (drive) {
return [drive.device_id, drive.device_desc, 'locker', drive.locker_location]
.filter(Boolean).join(' ').toLowerCase()
}
// Every word typed has to appear somewhere in the drive's text, in any order,
// so "engineer kingston" and "kingston engineer" find the same drive. An empty
// query matches everything - a blank box is not a filter.
export function matchesdrive (drive, query) {
const words = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return true
const text = drivetext(drive)
return words.every(word => text.includes(word))
}
export function filterdrives (drives, query) {
const words = String(query || '').trim()
if (!words) return drives
return drives.filter(drive => matchesdrive(drive, query))
}

View File

@@ -0,0 +1,62 @@
// The kiosk filter. Every case here is something somebody standing at the
// cabinet would actually type, including the phrasing they use out loud -
// "locker engineer" rather than the bare locker name.
import { describe, it, expect } from 'vitest'
import { filterdrives, matchesdrive } from './drivefilter'
const DRIVES = [
{ device_id: 'USB-3', device_desc: 'Kingston 16GB', locker_location: 'DT' },
{ device_id: 'USB-4', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-10', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-11', device_desc: 'Kingston 64GB', locker_location: 'B11' },
]
describe('filterdrives', () => {
it('an empty box is not a filter', () => {
expect(filterdrives(DRIVES, '')).toHaveLength(4)
expect(filterdrives(DRIVES, ' ')).toHaveLength(4)
})
it('finds a drive by its id', () => {
expect(filterdrives(DRIVES, 'usb-4').map(d => d.device_id)).toEqual(['USB-4'])
})
it('finds drives by what is written on them', () => {
expect(filterdrives(DRIVES, 'kingston').map(d => d.device_id))
.toEqual(['USB-3', 'USB-11'])
})
it('finds a locker by name', () => {
expect(filterdrives(DRIVES, 'engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('finds a locker the way somebody says it out loud', () => {
expect(filterdrives(DRIVES, 'locker engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('takes the words in any order', () => {
expect(filterdrives(DRIVES, 'sandisk engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
expect(filterdrives(DRIVES, 'engineer sandisk').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('ignores case and stray spacing', () => {
expect(filterdrives(DRIVES, ' ENGINEER SanDisk ').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('matches nothing rather than everything when nothing matches', () => {
expect(filterdrives(DRIVES, 'zzz')).toEqual([])
})
it('does not fall over on a drive missing its description or locker', () => {
const sparse = [{ device_id: 'USB-9' }]
expect(matchesdrive(sparse[0], 'usb-9')).toBe(true)
expect(matchesdrive(sparse[0], 'engineer')).toBe(false)
})
})

View File

@@ -58,12 +58,27 @@
</div>
<div class="drive-group">
<h2>Available</h2>
<div class="drive-group-head">
<h2>Available</h2>
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
thirty drives is faster to narrow than to read. -->
<div v-if="session.available.length" class="drive-filter">
<input ref="filterInput" v-model="drivefilter"
class="drive-filter-input"
autocomplete="off" spellcheck="false"
placeholder="Find a drive, a locker..." />
<button v-if="drivefilter" class="drive-filter-clear"
title="Clear" @click="drivefilter = ''">Clear</button>
</div>
</div>
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<p v-else-if="!availabledrives.length" class="kiosk-hint">
No free drive matches "{{ drivefilter }}".
</p>
<div v-else class="drive-list">
<button v-for="drive in session.available" :key="drive.device_id"
<button v-for="drive in availabledrives" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkout')">
<span class="drive-name">
@@ -117,9 +132,10 @@
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
const step = ref('badge')
const badge = ref('')
@@ -155,6 +171,20 @@ function focusEntry (event) {
nextTick(() => entryInput.value?.focus())
}
// Narrowing the free drives. The matcher lives in its own module so it can be
// tested without mounting a kiosk.
const drivefilter = ref('')
const filterInput = ref(null)
// Focused as the picker opens, so somebody can simply start typing. The badge
// step owns the keyboard until then.
function focusFilter () {
nextTick(() => filterInput.value?.focus())
}
const availabledrives = computed(
() => filterdrives(session.value.available, drivefilter.value))
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
// plain text for ids that carry no digits.
@@ -181,6 +211,7 @@ async function startSession (value) {
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
focusFilter()
} catch (sessionError) {
error.value = messageFrom(sessionError, 'Could not look that badge up')
} finally {
@@ -247,6 +278,9 @@ function reset () {
error.value = ''
doneMessage.value = ''
manualBadge.value = ''
// The next person at the cabinet must not inherit somebody else's filter and
// conclude there are no drives left.
drivefilter.value = ''
focusEntry()
}
</script>
@@ -273,7 +307,7 @@ function reset () {
}
.kiosk-header {
width: 100%;
max-width: 40rem;
max-width: 72rem;
display: flex;
justify-content: center;
align-items: center;
@@ -297,8 +331,11 @@ function reset () {
max-width: 40rem;
width: 100%;
}
/* The picker holds lists, so its rows stretch - but it keeps the same width as
every other step so the panel does not jump when the step changes.
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
that hold a single control - a cabinet of thirty drives is three or four
columns of one glance rather than a scroll. The steps that are just a keypad
or a message stay narrow, because stretching one input across a 24 inch panel
reads as a broken page.
It is also height-bounded: the lists absorb whatever space is left over and
scroll inside themselves, so the PAGE never scrolls and the heading saying
whose drives these are stays on screen. A fixed max-height cannot do this -
@@ -306,6 +343,7 @@ function reset () {
present, which is exactly what it did. */
.kiosk-wide {
align-items: stretch;
max-width: 72rem;
max-height: calc(100vh - 9rem);
min-height: 0;
}
@@ -321,7 +359,7 @@ function reset () {
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
.kiosk-error {
padding: 0.9rem 1.1rem;
border: 1px solid var(--danger, #b3261e);
@@ -335,12 +373,47 @@ function reset () {
.drive-group { width: 100%; }
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
rather than a scroll. The list itself scrolls when it has to - the PAGE must
not, or the person loses the heading telling them whose drives these are. */
/* Heading and filter on one line: the filter belongs to the list under it, and
a row of its own would cost height the lists need. */
.drive-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
.drive-filter-input {
/* Touch-sized like everything else here, and wide enough for a locker name
rather than just a drive number. */
min-width: 16rem;
padding: 0.6rem 0.8rem;
font-size: 1.1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card-solid);
color: var(--text);
}
.drive-filter-input:focus {
outline: none;
border-color: var(--primary);
}
.drive-filter-clear {
padding: 0.6rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
}
/* As many columns as the panel has room for - four on a wall display, one on a
small screen - so a cabinet of a dozen drives is one glance rather than a
scroll. The list itself scrolls when it has to; the PAGE must not, or the
person loses the heading telling them whose drives these are. */
.drive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
flex: 1 1 auto;
min-height: 0;
@@ -364,7 +437,7 @@ function reset () {
}
.drive-row:disabled { opacity: 0.5; }
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
.drive-name small { color: var(--textmuted); font-size: 0.85rem; }
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
.drive-action {
flex: 0 0 auto;
padding: 0.5rem 1.1rem;