Skip to content
Merged
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
9 changes: 6 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,9 +487,12 @@ interprets.
user labels and per-app labels. User and device petnames are generated at
founding/device creation; an app petname is generated before its first
launch. Explicitly saving an empty petname generates and persists a
replacement. Draft re-rolls happen synchronously in the visor from the same
EFF generator using its own `wasi:random` import; no naming policy crosses
the internal API. They are root scalar
replacement. Draft petname re-rolls happen synchronously in the visor from
the same EFF generator using its own `wasi:random` import; glyph re-rolls
similarly choose from exactly U+1F400..U+1F43F and exclude the current
character. Native emoji fonts remain preferred, with a self-hosted
monochrome Noto Emoji subset as the reliable fallback. No naming or glyph
policy crosses the internal API. They are root scalar
keys in collision-safe namespaces (`identity:`, `user:`, `app:`), so edits
to different fields do not replace a nested map. Device labels remain
member records keyed by endpoint public key in plaintext `us`; the local
Expand Down
97 changes: 95 additions & 2 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,55 @@ const scenarios: Scenario[] = [
},
},

{
name: "animal-glyph-font",
async run(ctx, origin) {
const page = await open(ctx, `${origin}${SUBPATH}`);
await visorReady(page);
const animals = Array.from(
{ length: 64 },
(_, offset) => String.fromCodePoint(0x1f400 + offset),
).join("");
const covered = "🎲" + animals;
await page.evaluate(async (text) => {
const probe = document.createElement("div");
probe.id = "animal-font-probe";
probe.style.cssText =
"font:400 32px 'Polyvisor Noto Emoji';display:grid;position:fixed;" +
"inset:0;z-index:999;background:white;align-content:center;justify-content:center;" +
"grid-template-columns:repeat(8,40px);line-height:40px;";
for (const glyph of text) {
const cell = document.createElement("span");
cell.textContent = glyph;
probe.append(cell);
}
document.body.append(probe);
await document.fonts.load("400 32px 'Polyvisor Noto Emoji'", text);
}, covered);
const fontResponse = await page.request.get(
`${origin}${SUBPATH}fonts/noto-emoji-animals.woff2`,
);
check(fontResponse.ok(), "subpath animal fallback font was not served");
const cdp = await ctx.newCDPSession(page);
await cdp.send("DOM.enable");
await cdp.send("CSS.enable");
const { root } = await cdp.send("DOM.getDocument");
const { nodeId } = await cdp.send("DOM.querySelector", {
nodeId: root.nodeId,
selector: "#animal-font-probe",
});
const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId });
const custom = fonts.filter((font) => font.isCustomFont);
check(custom.length === 1, `expected one custom animal font, got ${JSON.stringify(fonts)}`);
eq(custom[0].glyphCount, 65, "bundled font did not render the die and all 64 animal glyphs");
check(
fonts.every((font) => font.isCustomFont || font.glyphCount === 0),
`an installed fallback rendered part of the controlled grid: ${JSON.stringify(fonts)}`,
);
await shot(page, "animal-font-grid");
},
},

{
name: "petname-dice",
async run(ctx, origin, browser) {
Expand All @@ -2208,6 +2257,9 @@ const scenarios: Scenario[] = [
const device = field(/^device petname$/);
const userDie = drawer(page).getByRole("button", { name: "Re-roll user petname" });
const deviceDie = drawer(page).getByRole("button", { name: "Re-roll device petname" });
const userGlyphButton = drawer(page).getByRole("button", { name: "Choose your glyph" });
const userGlyphDie = drawer(page).getByRole("button", { name: "Re-roll user glyph" });
const glyphValue = (button: Locator) => button.locator(".glyph-tile-face").textContent();

const assertDieGap = async (input: Locator, die: Locator, where: string) => {
const inputBox = await input.boundingBox();
Expand Down Expand Up @@ -2242,32 +2294,70 @@ const scenarios: Scenario[] = [
await die.click();
await waitForInputToDiffer(input, first);
}
await shot(page, "desktop-petname-roll");

eq(await userGlyphDie.getAttribute("aria-disabled"), null, "blank user glyph die was disabled");
await userGlyphDie.click();
const firstUserGlyph = await glyphValue(userGlyphButton);
check(firstUserGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstUserGlyph), "user glyph roll left the animal range");
await userGlyphDie.click();
const secondUserGlyph = await glyphValue(userGlyphButton);
check(secondUserGlyph !== firstUserGlyph, "user glyph repeated its previous roll");
await userGlyphButton.click();
let glyphPicker = drawer(page).locator(".glyph-picker");
await glyphPicker.getByRole("searchbox", { name: "Enter glyph or search" }).fill("★");
await glyphPicker.getByRole("button", { name: "Use ★" }).click();
eq(await userGlyphDie.getAttribute("aria-disabled"), "true", "manual user glyph remained rerollable");
await userGlyphButton.click();
glyphPicker = drawer(page).locator(".glyph-picker");
await glyphPicker.getByRole("button", { name: "Clear glyph" }).click();
eq(await userGlyphDie.getAttribute("aria-disabled"), null, "cleared user glyph die was disabled");
await userGlyphDie.click();
const savedUserGlyph = await glyphValue(userGlyphButton);
await shot(page, "desktop-animal-glyph-roll");
await saveDraft(page);
await appsButton(page).click();
await paneSettled(page);
await openSettingsSheet(page);
eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), null, "saved roll lost eligibility on reopen");
eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph" }).getAttribute("aria-disabled"), null, "saved glyph roll lost eligibility on reopen");
eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose your glyph" })), savedUserGlyph, "saved user glyph changed on reopen");

await launchTodoMvc(page);
await toAppSheet(page);
const app = field(/^petname$/);
const appDie = drawer(page).getByRole("button", { name: "Re-roll app petname" });
const appGlyphButton = drawer(page).getByRole("button", { name: "Choose glyph", exact: true });
const appGlyphDie = drawer(page).getByRole("button", { name: "Re-roll app glyph" });
eq(await appDie.getAttribute("aria-disabled"), "true", "generated app default was rerollable");
eq(await appGlyphDie.getAttribute("aria-disabled"), null, "blank app glyph die was disabled");
await appGlyphDie.click();
const firstAppGlyph = await glyphValue(appGlyphButton);
check(firstAppGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstAppGlyph), "app glyph roll left the animal range");
await appGlyphDie.click();
check(await glyphValue(appGlyphButton) !== firstAppGlyph, "app glyph repeated its previous roll");
await app.fill("");
await appDie.click();
await page.waitForFunction(() => {
const label = [...document.querySelectorAll("label")].find((node) => node.textContent?.trim() === "petname");
return (label?.querySelector("input") as HTMLInputElement | null)?.value.length;
});
await drawer(page).getByRole("button", { name: "Revert", exact: true }).click();
await appGlyphDie.click();
const savedAppGlyph = await glyphValue(appGlyphButton);
await saveDraft(page);
await openSettingsSheet(page);
await paneSettled(page);
await toAppSheet(page);
eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose glyph", exact: true })), savedAppGlyph, "saved app glyph changed on reopen");
eq(await drawer(page).getByRole("button", { name: "Re-roll app glyph" }).getAttribute("aria-disabled"), null, "saved app glyph roll lost eligibility on reopen");
await pageCleanDrawer(page);
await page.reload();
await visorReady(page);
await page.waitForSelector("#app-zone iframe[sandbox]", { timeout: 30_000 });
await page.waitForFunction(() => document.querySelector("#visor-drawer") === null);
await openSettingsSheet(page);
eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), "true", "reload retained roll eligibility");
eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph" }).getAttribute("aria-disabled"), "true", "reload retained glyph roll eligibility");
check(await drawer(page).getByText("word", { exact: true }).count() === 0, "obsolete word field is still rendered");

const reloadedUser = field(/^your petname$/);
Expand All @@ -2279,14 +2369,16 @@ const scenarios: Scenario[] = [
check((await reloadedDevice.inputValue()).length > 0, "blank device save was not normalized");

await toAppSheet(page);
eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose glyph", exact: true })), savedAppGlyph, "app glyph did not survive reload");
eq(await drawer(page).getByRole("button", { name: "Re-roll app glyph" }).getAttribute("aria-disabled"), "true", "reload retained app glyph roll eligibility");
const savedApp = field(/^petname$/);
await savedApp.fill("");
await saveDraft(page);
check((await savedApp.inputValue()).length > 0, "blank app save was not normalized");

await page.setViewportSize({ width: 390, height: 844 });
await assertDieGap(field(/^petname$/), drawer(page).getByRole("button", { name: "Re-roll app petname" }), "mobile app");
await shot(page, "mobile-petname-roll");
await shot(page, "mobile-animal-glyph-roll");
const touch = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true });
try {
const mobile = await open(touch, origin);
Expand Down Expand Up @@ -3138,6 +3230,7 @@ const scenarios: Scenario[] = [

// Escape is that button by another name, and the caret goes back to
// the half of the strip that raised the pane.
await drawer(page).locator(".pane").focus();
await page.keyboard.press("Escape");
await page.waitForFunction(
() => document.querySelector("#visor-drawer") === null,
Expand Down
2 changes: 2 additions & 0 deletions visor/src/draft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ pub(crate) enum RollTarget {
Device,
User,
App(String),
UserGlyph,
AppGlyph(String),
Picker,
}

Expand Down
49 changes: 48 additions & 1 deletion visor/src/glyph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

use unicode_segmentation::UnicodeSegmentation;

const ANIMAL_FIRST: u32 = 0x1f400;
const ANIMAL_COUNT: u32 = 64;

/// Choose one of the 64 animal characters U+1F400..U+1F43F.
///
/// When `previous` is in that range, map over the other 63 rather than
/// retrying: a roll is synchronous and is guaranteed to change.
pub(crate) fn roll_animal(random: u32, previous: &str) -> String {
let previous = previous
.chars()
.next()
.filter(|_| previous.chars().count() == 1)
.map(u32::from)
.filter(|code| (ANIMAL_FIRST..ANIMAL_FIRST + ANIMAL_COUNT).contains(code));
let offset = match previous {
Some(code) => {
let previous_offset = code - ANIMAL_FIRST;
let candidate = random % (ANIMAL_COUNT - 1);
candidate + u32::from(candidate >= previous_offset)
}
None => random % ANIMAL_COUNT,
};
char::from_u32(ANIMAL_FIRST + offset)
.expect("the animal emoji range contains Unicode scalar values")
.to_string()
}

/// Strip leading Unicode whitespace and retain one extended grapheme.
///
/// There is deliberately no normalization and no trailing trim: the first
Expand All @@ -12,7 +39,27 @@ pub(crate) fn normalize_glyph(value: &str) -> &str {

#[cfg(test)]
mod tests {
use super::normalize_glyph;
use super::{normalize_glyph, roll_animal};

#[test]
fn animal_roll_covers_range_boundaries_and_skips_previous() {
assert_eq!(roll_animal(0, ""), "\u{1f400}");
assert_eq!(roll_animal(63, ""), "\u{1f43f}");
assert_eq!(roll_animal(64, ""), "\u{1f400}");
assert_eq!(roll_animal(0, "\u{1f400}"), "\u{1f401}");
assert_eq!(roll_animal(62, "\u{1f43f}"), "\u{1f43e}");
for previous in 0..64 {
let old = char::from_u32(0x1f400 + previous).unwrap().to_string();
let rolls: std::collections::BTreeSet<_> =
(0..63).map(|random| roll_animal(random, &old)).collect();
assert_eq!(rolls.len(), 63);
assert!(!rolls.contains(&old));
assert!(rolls.iter().all(|glyph| {
let code = u32::from(glyph.chars().next().unwrap());
(0x1f400..=0x1f43f).contains(&code)
}));
}
}

#[test]
fn keeps_one_extended_grapheme_after_leading_space() {
Expand Down
20 changes: 19 additions & 1 deletion visor/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
//! contrast does not depend on what happens to be behind it.

pub(crate) const CSS: &str = r#"
@font-face {
font-family: "Polyvisor Noto Emoji";
src: url("fonts/noto-emoji-animals.woff2") format("woff2");
font-style: normal;
font-weight: 400;
font-display: swap;
unicode-range: U+1F3B2, U+1F400-1F43F, U+FE0F;
}
/* The two arms of the anchor rule, and the only place either is spelled.
Claimed: every colour is `--hue` at a fixed lightness/chroma. Unclaimed:
the same lightnesses at zero chroma, so an unpainted visor is the same
Expand Down Expand Up @@ -110,7 +118,9 @@ pub(crate) const CSS: &str = r#"
justify-content: center;
background: var(--strip-edge);
color: var(--accent-ink);
font-size: 28px; line-height: 1; font-weight: 600;
font-size: 28px; line-height: 1; font-weight: 400;
font-family: Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji,
"Polyvisor Noto Emoji", emoji, sans-serif;
width: 36px; height: 36px;
}
#visor-root .glyph-tile-face {
Expand Down Expand Up @@ -301,6 +311,10 @@ pub(crate) const CSS: &str = r#"
#visor-root .petname-control > label { flex: 0 1 auto; min-width: 0; margin-bottom: 0; }
#visor-root .roll-control { position: relative; flex: none; }
#visor-root .roll-button { min-width: 44px; min-height: 44px; padding: 6px 10px; }
#visor-root .roll-glyph {
font: 400 20px/1 Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji,
"Polyvisor Noto Emoji", emoji, sans-serif;
}
#visor-root .roll-button[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
#visor-root .roll-tooltip {
position: absolute; z-index: 3; right: 0; top: calc(100% + 4px);
Expand Down Expand Up @@ -329,6 +343,10 @@ pub(crate) const CSS: &str = r#"
gap: 4px; max-height: min(16rem, 36vh); overflow-y: auto;
}
#visor-root .glyph-results button { padding: 4px; font-size: 24px; }
#visor-root .glyph-face {
font-family: Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji,
"Polyvisor Noto Emoji", emoji, sans-serif;
}
#visor-root .glyph-picker .glyph-clear { align-self: flex-start; font-size: inherit; }

/* Rows wrap rather than overlap: a name and some framework-voice facts about
Expand Down
Loading
Loading