feat: add RTL support

pull/8104/head
NGPixel 2 days ago
parent 45980cd286
commit 19edf633ae
No known key found for this signature in database

@ -338,7 +338,19 @@ function bodyForPage(page: PageDescription): string {
if (!page.render) { if (!page.render) {
return '' return ''
} }
return `<div id="${PRERENDER_ID}">${stripActiveMarkup(page.render)}</div>` return `<div id="${PRERENDER_ID}"${languageAttrs(page.locale)}>${stripActiveMarkup(page.render)}</div>`
}
/**
* The `lang` and `dir` of the copy a page is served with, as the app puts them on the article.
*
* The shell's own `<html lang>` is the interface's and says nothing about the page, which may be
* written in any of the site's locales — and a right-to-left page drawn left to right is punctuated at
* the wrong end of every line.
*/
function languageAttrs(locale: string): string {
const dir = WIKI.models.locales.isRTL(locale) ? 'rtl' : 'ltr'
return ` lang="${htmlEscape(locale)}" dir="${dir}"`
} }
/** /**
@ -385,7 +397,7 @@ async function bodyForBlog(siteId: string, page: PageDescription): Promise<strin
if (!intro && items.length < 1) { if (!intro && items.length < 1) {
return '' return ''
} }
return `<div id="${PRERENDER_ID}">${intro}<ul>${items}</ul></div>` return `<div id="${PRERENDER_ID}"${languageAttrs(page.locale)}>${intro}<ul>${items}</ul></div>`
} }
/** /**

@ -2012,6 +2012,7 @@
"common.page.global": "Global", "common.page.global": "Global",
"common.page.id": "ID {id}", "common.page.id": "ID {id}",
"common.page.lastEditedBy": "Last edited by", "common.page.lastEditedBy": "Last edited by",
"common.page.lastModified": "Last modified on {date}",
"common.page.loading": "Loading Page...", "common.page.loading": "Loading Page...",
"common.page.locked": "This page is password protected.", "common.page.locked": "This page is password protected.",
"common.page.lockedHint": "Enter the password to read it.", "common.page.lockedHint": "Enter the password to read it.",

@ -568,6 +568,15 @@ class Locales {
return (WIKI.cache?.get(`locale:${code}`) as any)?.displayCode ?? code return (WIKI.cache?.get(`locale:${code}`) as any)?.displayCode ?? code
} }
/**
* Whether a locale's script runs right to left. Sync for the same reason as `shortCodeFor`: the
* document a page is served in is assembled without awaiting anything per locale. A code the cache
* does not hold reads as left to right.
*/
isRTL(code: string): boolean {
return Boolean((WIKI.cache?.get(`locale:${code}`) as any)?.isRTL)
}
/** /**
* The locale a short code names, whichever of its codes was used. * The locale a short code names, whichever of its codes was used.
* *

@ -116,7 +116,7 @@
} }
#wiki-prerender ul, #wiki-prerender ul,
#wiki-prerender ol { #wiki-prerender ol {
padding-left: 2em; padding-inline-start: 2em;
list-style: revert; list-style: revert;
} }
#wiki-prerender pre, #wiki-prerender pre,
@ -125,12 +125,13 @@
} }
#wiki-prerender pre { #wiki-prerender pre {
overflow-x: auto; overflow-x: auto;
direction: ltr;
padding: .75em 1em; padding: .75em 1em;
background: #f4f4f4; background: #f4f4f4;
} }
#wiki-prerender blockquote { #wiki-prerender blockquote {
padding-left: 1em; padding-inline-start: 1em;
border-left: 4px solid #ccc; border-inline-start: 4px solid #ccc;
color: #555; color: #555;
} }
#wiki-prerender img { #wiki-prerender img {

@ -5,10 +5,11 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts. removing an icon; `check-icons.mjs` fails the build if this drifts.
283 icons. 284 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32}, "la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
"la:angle-left": {"body":"<path fill=\"currentColor\" d=\"m19.031 4.281l-11 11l-.687.719l.687.719l11 11l1.438-1.438L10.187 16L20.47 5.719z\"/>","width":32,"height":32},
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
"la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32}, "la:arrow-circle-left": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m-.719 4.594L8.875 16l6.406 6.406L16.72 21l-4-4H23v-2H12.719l4-4z\"/>","width":32,"height":32},
"la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32}, "la:arrow-circle-right": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.832 3 3 8.832 3 16s5.832 13 13 13s13-5.832 13-13S23.168 3 16 3m0 2c6.086 0 11 4.914 11 11s-4.914 11-11 11S5 22.086 5 16S9.914 5 16 5m.719 4.594L15.28 11l4 4H9v2h10.281l-4 4l1.438 1.406L23.125 16z\"/>","width":32,"height":32},

@ -276,6 +276,8 @@
--> -->
<div <div
class="editor-asciidoc-preview-content page-contents is-asciidoc" class="editor-asciidoc-preview-content page-contents is-asciidoc"
:lang="pageStore.locale"
:dir="siteStore.localeDir(pageStore.locale)"
ref="editorPreviewContainerRef" ref="editorPreviewContainerRef"
v-html="pageStore.render" /> v-html="pageStore.render" />
</div> </div>

@ -317,6 +317,8 @@
--> -->
<div <div
class="editor-markdown-preview-content page-contents" class="editor-markdown-preview-content page-contents"
:lang="pageStore.locale"
:dir="siteStore.localeDir(pageStore.locale)"
ref="editorPreviewContainerRef" ref="editorPreviewContainerRef"
v-html="pageStore.render" /> v-html="pageStore.render" />
</div> </div>

@ -342,7 +342,12 @@
@paste="onEditorPaste" @paste="onEditorPaste"
@dragover="onEditorDragOver" @dragover="onEditorDragOver"
@drop="onEditorDrop"> @drop="onEditorDrop">
<div ref="mountRef" class="editor-visual-surface" /> <!-- -> The page's language and direction, inherited by the editable ProseMirror mounts in it -->
<div
ref="mountRef"
class="editor-visual-surface"
:lang="pageStore.locale"
:dir="siteStore.localeDir(pageStore.locale)" />
</div> </div>
</div> </div>
</div> </div>

@ -5,7 +5,16 @@
:anchor="props.anchor" :anchor="props.anchor"
:self="props.self" :self="props.self"
:offset="props.offset"> :offset="props.offset">
<w-list padding style="min-width: 200px"> <!--
The list runs the way the interface does -- it is teleported, so it says so itself -- while each
name is written in, and marked as, its own language: a menu of languages is the one place every row
may be in a different one.
-->
<w-list
padding
style="min-width: 200px"
:lang="commonStore.locale"
:dir="siteStore.localeDir(commonStore.locale)">
<w-item <w-item
v-for="lang of siteStore.locales.active" v-for="lang of siteStore.locales.active"
:key="lang.code" :key="lang.code"
@ -23,7 +32,11 @@
</w-avatar> </w-avatar>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ lang.displayName }}</w-item-label> <w-item-label>
<span :lang="lang.code" :dir="siteStore.localeDir(lang.code)">{{
lang.displayName
}}</span>
</w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </w-list>

@ -8,10 +8,12 @@
@show="onShow"> @show="onShow">
<!-- -> A fixed width: the levels slide sideways past each other, so a panel that resized to its <!-- -> A fixed width: the levels slide sideways past each other, so a panel that resized to its
contents would jump mid-slide. Long titles truncate instead. --> contents would jump mid-slide. Long titles truncate instead. -->
<div class="browse-menu-panel"> <!-- -> Set here rather than inherited: the menu is teleported to the body, out from under the
drawer that carries the interface's direction -->
<div class="browse-menu-panel" :lang="commonStore.locale" :dir="dir">
<div class="browse-menu-header flex flex-nowrap items-center"> <div class="browse-menu-header flex flex-nowrap items-center">
<!-- -> There is nowhere to go up to from the root, so the button is absent there rather than <!-- -> There is nowhere to go up to from the root, so the button is absent there rather than
sitting disabled. It slides in from the left as its own space opens up, and back out sitting disabled. It slides in from the start as its own space opens up, and back out
the same way, so the title beside it moves with it instead of jumping. --> the same way, so the title beside it moves with it instead of jumping. -->
<transition name="browse-menu-up"> <transition name="browse-menu-up">
<div v-if="!isRoot" class="browse-menu-up-slot"> <div v-if="!isRoot" class="browse-menu-up-slot">
@ -33,7 +35,11 @@
<!-- -> The root has no title of its own, and the site is already named in the sidebar <!-- -> The root has no title of its own, and the site is already named in the sidebar
header directly above this, so there the path stands alone --> header directly above this, so there the path stands alone -->
<div v-if="level.title" class="truncate text-sm font-medium">{{ level.title }}</div> <div v-if="level.title" class="truncate text-sm font-medium">{{ level.title }}</div>
<div class="text-caption truncate opacity-60 font-robotomono">/{{ state.path }}</div> <!-- -> A path reads left to right whatever the language around it, or the slashes would be
reordered to the far end of a right-to-left segment -->
<div class="text-caption truncate opacity-60 font-robotomono">
<span dir="ltr">/{{ state.path }}</span>
</div>
</div> </div>
</div> </div>
<w-separator /> <w-separator />
@ -70,7 +76,7 @@
<w-icon name="img:/_assets/icons/fluent-folder.svg" size="xs" class="shrink-0" /> <w-icon name="img:/_assets/icons/fluent-folder.svg" size="xs" class="shrink-0" />
<span class="truncate">{{ item.title }}</span> <span class="truncate">{{ item.title }}</span>
<w-space /> <w-space />
<w-icon name="la:angle-right" size="xs" class="shrink-0 opacity-40" /> <w-icon :name="forwardIcon" size="xs" class="shrink-0 opacity-40" />
</button> </button>
<button <button
v-if="item.isPage && item.isFolder" v-if="item.isPage && item.isFolder"
@ -79,7 +85,7 @@
:aria-label="t(`common.browse.openFolder`, { title: item.title })" :aria-label="t(`common.browse.openFolder`, { title: item.title })"
@click="descend(item)"> @click="descend(item)">
<w-tooltip>{{ t('common.browse.openFolder', { title: item.title }) }}</w-tooltip> <w-tooltip>{{ t('common.browse.openFolder', { title: item.title }) }}</w-tooltip>
<w-icon name="la:angle-right" size="xs" class="opacity-70" /> <w-icon :name="forwardIcon" size="xs" class="opacity-70" />
</button> </button>
</div> </div>
<div v-if="level.items.length < 1" class="browse-menu-note"> <div v-if="level.items.length < 1" class="browse-menu-note">
@ -101,6 +107,7 @@ import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useCommonStore } from '@/stores/common'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -134,6 +141,7 @@ const props = defineProps({
// STORES // STORES
const commonStore = useCommonStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
@ -166,6 +174,12 @@ const state = reactive({
// COMPUTED // COMPUTED
/** The interface's direction, which the panel has to state itself -- see the template. */
const dir = computed(() => siteStore.localeDir(commonStore.locale))
/** The chevron into a folder points the way the line runs, which right to left is to the left. */
const forwardIcon = computed(() => (dir.value === 'rtl' ? 'la:angle-left' : 'la:angle-right'))
const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL) const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
const isRoot = computed(() => !state.path) const isRoot = computed(() => !state.path)
@ -259,8 +273,19 @@ function goUp() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/*
`--browse-menu-x` is which way "forward" is along the line: 1 when it runs to the right, -1 when it
runs to the left. Every sideways slide below is multiplied by it, so a level entered on a
right-to-left interface arrives from the left -- the side its chevron pointed to.
*/
.browse-menu-panel { .browse-menu-panel {
--browse-menu-x: 1;
width: 270px; width: 270px;
&[dir='rtl'] {
--browse-menu-x: -1;
}
} }
/* /*
@ -270,12 +295,13 @@ function goUp() {
20px path -- plus the space around them. 20px path -- plus the space around them.
That leaves 12px above and below the 28px button, which is where the 12px beside it comes from: the That leaves 12px above and below the 28px button, which is where the 12px beside it comes from: the
gap around it reads as even only if all four sides match. The left one is this padding, the right gap around it reads as even only if all four sides match. The one before it is this padding, the one
one is the slot's own margin below. after it is the slot's own margin below.
*/ */
.browse-menu-header { .browse-menu-header {
height: 52px; height: 52px;
padding: 0 8px 0 12px; padding-block: 0;
padding-inline: 12px 8px;
} }
/* /*
@ -305,7 +331,7 @@ function goUp() {
.browse-menu-up-slot { .browse-menu-up-slot {
flex: none; flex: none;
width: 28px; width: 28px;
margin-right: 12px; margin-inline-end: 12px;
} }
/* /*
@ -317,7 +343,7 @@ function goUp() {
overflow: hidden; overflow: hidden;
transition: transition:
width 0.18s var(--ease-standard), width 0.18s var(--ease-standard),
margin-right 0.18s var(--ease-standard), margin-inline-end 0.18s var(--ease-standard),
opacity 0.18s var(--ease-standard); opacity 0.18s var(--ease-standard);
} }
@ -331,13 +357,13 @@ function goUp() {
.browse-menu-up-enter-from, .browse-menu-up-enter-from,
.browse-menu-up-leave-to { .browse-menu-up-leave-to {
width: 0; width: 0;
margin-right: 0; margin-inline-end: 0;
opacity: 0; opacity: 0;
} }
.browse-menu-up-enter-from .browse-menu-up, .browse-menu-up-enter-from .browse-menu-up,
.browse-menu-up-leave-to .browse-menu-up { .browse-menu-up-leave-to .browse-menu-up {
transform: translateX(-100%); transform: translateX(calc(-100% * var(--browse-menu-x)));
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@ -357,7 +383,7 @@ function goUp() {
gap: 8px; gap: 8px;
padding: 6px 12px; padding: 6px 12px;
font-size: 13px; font-size: 13px;
text-align: left; text-align: start;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
@ -383,10 +409,10 @@ function goUp() {
padding: 0 10px; padding: 0 10px;
cursor: pointer; cursor: pointer;
/* -> The seam that says the row has two hit targets rather than one */ /* -> The seam that says the row has two hit targets rather than one */
border-left: 1px solid rgb(0 0 0 / 0.08); border-inline-start: 1px solid rgb(0 0 0 / 0.08);
@at-root .body--dark & { @at-root .body--dark & {
border-left-color: rgb(255 255 255 / 0.12); border-inline-start-color: rgb(255 255 255 / 0.12);
} }
} }
@ -439,18 +465,18 @@ function goUp() {
.browse-menu-back-leave-active { .browse-menu-back-leave-active {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; inset-inline-start: 0;
} }
.browse-menu-forward-enter-from, .browse-menu-forward-enter-from,
.browse-menu-back-leave-to { .browse-menu-back-leave-to {
transform: translateX(100%); transform: translateX(calc(100% * var(--browse-menu-x)));
opacity: 0; opacity: 0;
} }
.browse-menu-forward-leave-to, .browse-menu-forward-leave-to,
.browse-menu-back-enter-from { .browse-menu-back-enter-from {
transform: translateX(-100%); transform: translateX(calc(-100% * var(--browse-menu-x)));
opacity: 0; opacity: 0;
} }

@ -1,5 +1,9 @@
<template> <template>
<w-card style="min-width: 350px"> <!-- -> Teleported with the menu it opens in, so it states the interface's direction itself -->
<w-card
style="min-width: 350px"
:lang="commonStore.locale"
:dir="siteStore.localeDir(commonStore.locale)">
<w-card-section class="card-header"> <w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-sidebar-menu.svg" left size="sm" /> <w-icon name="img:/_assets/icons/fluent-sidebar-menu.svg" left size="sm" />
<span>{{ t(`navEdit.title`) }}</span> <span>{{ t(`navEdit.title`) }}</span>
@ -97,6 +101,7 @@ import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useCommonStore } from '@/stores/common'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
@ -116,6 +121,7 @@ const props = defineProps({
// STORES // STORES
const commonStore = useCommonStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()

@ -264,7 +264,7 @@ $sidebar-overlay-max: 1199.98px;
*/ */
.w-expansion-item__content { .w-expansion-item__content {
position: relative; position: relative;
border-left: 10px solid rgba(255, 255, 255, 0.25); border-inline-start: 10px solid rgba(255, 255, 255, 0.25);
/* /*
And a step DOWN from the sidebar rather than up, which is the one place this parts company with And a step DOWN from the sidebar rather than up, which is the one place this parts company with
`NavEditOverlay`: there the nested rows lift off a near-black panel, here they sit in a coloured `NavEditOverlay`: there the nested rows lift off a near-black panel, here they sit in a coloured
@ -285,32 +285,41 @@ $sidebar-overlay-max: 1199.98px;
/* /*
Each elbow is one 10px box showing two of its borders: the mitre between them is the angle. Set Each elbow is one 10px box showing two of its borders: the mitre between them is the angle. Set
10px outside the content on the appropriate side, so the vertical stroke lines up with the rule 10px outside the content on the appropriate side, so the vertical stroke lines up with the rule
and continues it. `left: -10px` is the rule's own left edge -- an absolute offset here is and continues it. `-10px` from the starting edge is the rule's own outer edge -- an absolute
measured from the padding box, which starts where the border ends. offset here is measured from the padding box, which starts where the border ends.
Every side is logical, so on a right-to-left interface the rule, the elbows and the turns all
mirror together.
*/ */
&::before, &::before,
&::after { &::after {
content: ''; content: '';
display: block; display: block;
position: absolute; position: absolute;
left: -10px; inset-inline-start: -10px;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-style: solid; border-style: solid;
border-color: transparent;
} }
/* -> Out of the parent row: the rule's top end, turning right into the row above it */ /* -> Out of the parent row: the rule's top end, turning inwards into the row above it */
&::before { &::before {
top: -10px; top: -10px;
border-width: 0 10px 10px 0; border-width: 0;
border-color: transparent transparent rgba(255, 255, 255, 0.25) rgba(255, 255, 255, 0.25); border-block-end-width: 10px;
border-inline-end-width: 10px;
border-block-end-color: rgba(255, 255, 255, 0.25);
border-inline-start-color: rgba(255, 255, 255, 0.25);
} }
/* -> And closed under the last child, turning right again */ /* -> And closed under the last child, turning inwards again */
&::after { &::after {
top: 100%; top: 100%;
border-width: 10px 10px 10px 0; border-width: 10px;
border-color: rgba(255, 255, 255, 0.25) transparent transparent rgba(255, 255, 255, 0.25); border-inline-start-width: 0;
border-block-start-color: rgba(255, 255, 255, 0.25);
border-inline-start-color: rgba(255, 255, 255, 0.25);
} }
} }
} }
@ -334,13 +343,30 @@ $sidebar-overlay-max: 1199.98px;
} }
/* /*
A child row starts 10px in, past the rule that marks the group -- and on this side that is the edge A child row starts 10px in, past the rule that marks the group -- and where that is the edge the
the notch is cut from, so it would be bitten out of the middle of the sidebar with a strip of colour notch is cut from, it would be bitten out of the middle of the sidebar with a strip of colour still
still outside it. Pushed back out to where the sidebar itself ends. outside it. Pushed back out to where the sidebar itself ends.
The other way round this does not arise: with the sidebar on the left the notch is on the right edge, The notch is on the side facing the page, which is the theme's to say; the indent is on the side the
and only the left of a child row is indented. text starts from, which is the interface language's. So the two meet in two of the four cases: a
sidebar on the right read left to right, and one on the left read right to left. `dir` is always set,
by `MainLayout`, on the drawer this sits in.
*/ */
[dir='ltr']
&--flipped
.w-list
.w-expansion-item__content
.w-item.router-link-exact-active::after {
left: -10px;
}
[dir='rtl']
&:not(&--flipped)
.w-list
.w-expansion-item__content
.w-item.router-link-exact-active::after {
right: -10px;
}
/* /*
No notch at all once the drawer overlays the page instead of taking a column beside it. No notch at all once the drawer overlays the page instead of taking a column beside it.

@ -3,7 +3,7 @@
<!-- Tags --> <!-- Tags -->
<template v-if="showTags"> <template v-if="showTags">
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:tags" color="grey" /> <w-icon class="me-2" name="la:tags" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.blog.tags') }}</div> <div class="text-caption text-grey-7">{{ t('common.blog.tags') }}</div>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
@ -27,7 +27,7 @@
<template v-if="showArchive"> <template v-if="showArchive">
<w-separator v-if="showTags" /> <w-separator v-if="showTags" />
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:calendar-alt" color="grey" /> <w-icon class="me-2" name="la:calendar-alt" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.blog.archive') }}</div> <div class="text-caption text-grey-7">{{ t('common.blog.archive') }}</div>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
@ -47,7 +47,7 @@
dense dense
size="sm" size="sm"
color="grey" color="grey"
:icon="isOpen(year.year) ? `la:angle-down` : `la:angle-right`" :icon="isOpen(year.year) ? `la:angle-down` : collapsedIcon"
:aria-label="t(`common.blog.toggleYear`, { year: year.year })" :aria-label="t(`common.blog.toggleYear`, { year: year.year })"
:aria-expanded="isOpen(year.year)" :aria-expanded="isOpen(year.year)"
@click="toggleYear(year.year)" /> @click="toggleYear(year.year)" />
@ -94,6 +94,7 @@ import { blogFilterFromQuery, blogFilterQuery } from '@/helpers/blogFilter'
import { parseBlog } from '@/helpers/pageBlog' import { parseBlog } from '@/helpers/pageBlog'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
/** /**
* The column beside a blog's listing: what the blog is about, and when it was written. * The column beside a blog's listing: what the blog is about, and when it was written.
@ -111,6 +112,7 @@ import { usePageStore } from '@/stores/page'
// STORES // STORES
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore()
// ROUTER // ROUTER
@ -132,6 +134,11 @@ const openYears = reactive(new Set())
// COMPUTED // COMPUTED
/** A closed year's chevron points along the line, which on a right-to-left page is to the left. */
const collapsedIcon = computed(() =>
siteStore.localeDir(pageStore.locale) === 'rtl' ? 'la:angle-left' : 'la:angle-right'
)
const settings = computed(() => parseBlog(pageStore.content)) const settings = computed(() => parseBlog(pageStore.content))
const filter = computed(() => blogFilterFromQuery(route.query)) const filter = computed(() => blogFilterFromQuery(route.query))
@ -251,7 +258,7 @@ function monthName(month) {
} }
&-count { &-count {
padding-left: 5px; padding-inline-start: 5px;
font-size: 0.65rem; font-size: 0.65rem;
opacity: 0.6; opacity: 0.6;
} }
@ -267,13 +274,13 @@ function monthName(month) {
&-toggle { &-toggle {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0; min-width: 0;
margin-right: 2px; margin-inline-end: 2px;
padding: 0 2px; padding: 0 2px;
} }
} }
.page-blog-months { .page-blog-months {
padding-left: 26px; padding-inline-start: 26px;
} }
.page-blog-month { .page-blog-month {
@ -309,7 +316,7 @@ function monthName(month) {
} }
&-count { &-count {
padding-left: 8px; padding-inline-start: 8px;
font-size: 0.68rem; font-size: 0.68rem;
opacity: 0.55; opacity: 0.55;
} }

@ -1,7 +1,16 @@
<template> <template>
<div class="page-header flex flex-wrap"> <!--
In the interface's language and direction: the row is chrome -- an icon, then what it is called,
then what can be done with it -- and on a right-to-left interface it runs from the right. The title
and description keep the PAGE's direction on their own text, below, which in the editor need not be
the same one: an editor is not a page path, so the interface there stays in the reader's language.
-->
<div
class="page-header flex flex-wrap"
:lang="commonStore.locale"
:dir="siteStore.localeDir(commonStore.locale)">
<!-- PAGE ICON --> <!-- PAGE ICON -->
<div class="flex-none pl-4 flex items-center"> <div class="flex-none ps-4 flex items-center">
<w-btn <w-btn
class="rounded" class="rounded"
v-if="isEditing" v-if="isEditing"
@ -37,12 +46,18 @@
and left at the top it sat above the middle of the icon beside it. A page that has one is taller and left at the top it sat above the middle of the icon beside it. A page that has one is taller
than everything else in the row, so there is nothing to centre and this changes nothing. than everything else in the row, so there is nothing to centre and this changes nothing.
--> -->
<div class="min-w-0 flex-1 flex flex-col justify-center p-2 sm:p-4"> <!--
The title and description are the page's own words, so they take its language and direction --
on the text alone rather than on the column, which would push them to the far side of the icon
they belong to.
-->
<div class="min-w-0 flex-1 flex flex-col justify-center p-2 sm:p-4" :lang="pageStore.locale">
<div class="text-h4 page-header-title"> <div class="text-h4 page-header-title">
<span <span
v-if="isEditing" v-if="isEditing"
ref="titleEl" ref="titleEl"
class="page-header-editable" class="page-header-editable"
:dir="contentDir"
:class="{ 'is-empty': !pageStore.title }" :class="{ 'is-empty': !pageStore.title }"
contenteditable="plaintext-only" contenteditable="plaintext-only"
role="textbox" role="textbox"
@ -52,13 +67,14 @@
@input="onEditableInput(`title`, $event)" @input="onEditableInput(`title`, $event)"
@blur="onEditableBlur(`title`, $event)" @blur="onEditableBlur(`title`, $event)"
@keydown.enter.prevent="$event.target.blur()" /> @keydown.enter.prevent="$event.target.blur()" />
<span v-else>{{ pageStore.title }}</span> <span v-else :dir="contentDir">{{ pageStore.title }}</span>
</div> </div>
<div class="text-subtitle2 page-header-subtitle"> <div class="text-subtitle2 page-header-subtitle">
<span <span
v-if="isEditing" v-if="isEditing"
ref="descriptionEl" ref="descriptionEl"
class="page-header-editable" class="page-header-editable"
:dir="contentDir"
:class="{ 'is-empty': !pageStore.description }" :class="{ 'is-empty': !pageStore.description }"
contenteditable="plaintext-only" contenteditable="plaintext-only"
role="textbox" role="textbox"
@ -68,7 +84,7 @@
@input="onEditableInput(`description`, $event)" @input="onEditableInput(`description`, $event)"
@blur="onEditableBlur(`description`, $event)" @blur="onEditableBlur(`description`, $event)"
@keydown.enter.prevent="$event.target.blur()" /> @keydown.enter.prevent="$event.target.blur()" />
<span v-else>{{ pageStore.description }}</span> <span v-else :dir="contentDir">{{ pageStore.description }}</span>
</div> </div>
</div> </div>
<!-- PAGE ACTIONS --> <!-- PAGE ACTIONS -->
@ -81,16 +97,6 @@
class="page-header-actions flex-none p-4 flex items-center justify-end" class="page-header-actions flex-none p-4 flex items-center justify-end"
:class="{ 'has-editor-actions': hasEditorActions }"> :class="{ 'has-editor-actions': hasEditorActions }">
<template v-if="!editorStore.isActive"> <template v-if="!editorStore.isActive">
<!--
Whoever is looking at a draft can already see it, so the badge is not gated on being logged
in the way the actions beside it are: it is telling a reader what they are reading, not
offering them something to do.
-->
<w-badge
v-if="pageStore.publishState === `draft`"
class="uppercase"
color="negative"
:label="t(`editor.props.draft`)" />
<!-- <!--
Watching a page is a state of it, so the button IS the state: filled and orange while the Watching a page is a state of it, so the button IS the state: filled and orange while the
page is watched, an outline in grey while it is not. Same orange as Edit, because both are page is watched, an outline in grey while it is not. Same orange as Edit, because both are
@ -105,7 +111,7 @@
and nobody is reading this one — they are passing through it. and nobody is reading this one — they are passing through it.
--> -->
<w-btn <w-btn
class="ml-4" class="ms-4"
:class="{ 'is-ringing': state.bellRinging }" :class="{ 'is-ringing': state.bellRinging }"
v-if="userStore.authenticated && !isRedirect" v-if="userStore.authenticated && !isRedirect"
flat flat
@ -120,7 +126,7 @@
</w-tooltip> </w-tooltip>
</w-btn> </w-btn>
<w-btn <w-btn
class="ml-4" class="ms-4"
v-if="siteStore.theme.showPrintBtn" v-if="siteStore.theme.showPrintBtn"
flat flat
dense dense
@ -143,7 +149,7 @@
one to review. one to review.
--> -->
<w-btn <w-btn
class="ml-4" class="ms-4"
v-if="pageStore.canReview && !isRedirect" v-if="pageStore.canReview && !isRedirect"
flat flat
dense dense
@ -165,11 +171,16 @@
</w-badge> </w-badge>
<w-tooltip>{{ t('inbox.pendingReview') }}</w-tooltip> <w-tooltip>{{ t('inbox.pendingReview') }}</w-tooltip>
<!-- <!--
Down from the button's right edge, like every other menu hanging off this row: the panel is Down from the button's far edge, like every other menu hanging off this row: the panel is
wider than the button and the button is near the right of the window, so aligning their wider than the button and the button is near the end of the row, so aligning the edges on
RIGHT edges is what keeps it on screen. that side is what keeps it on screen -- the right ones, or the left ones on a right-to-left
interface, where the actions are at the left of the window.
--> -->
<w-menu class="translucent-menu" anchor="bottom right" self="top right" auto-close> <w-menu
class="translucent-menu"
:anchor="`bottom ${endSide}`"
:self="`top ${endSide}`"
auto-close>
<w-list padding style="min-width: 320px"> <w-list padding style="min-width: 320px">
<w-item v-if="pendingCount < 1"> <w-item v-if="pendingCount < 1">
<w-item-section> <w-item-section>
@ -203,9 +214,9 @@
Whoever else has this page open in an editor. Renders nothing when that is nobody, which is Whoever else has this page open in an editor. Renders nothing when that is nobody, which is
also what it renders whenever there is no collaboration session at all. also what it renders whenever there is no collaboration session at all.
--> -->
<collab-presence class="mr-2" /> <collab-presence class="me-2" />
<w-btn <w-btn
class="ml-4 acrylic-btn" class="ms-4 acrylic-btn"
icon="la:question-circle" icon="la:question-circle"
flat flat
color="grey" color="grey"
@ -222,7 +233,7 @@
--> -->
<template v-if="!editorStore.isActive && userStore.can(`write:pages`)"> <template v-if="!editorStore.isActive && userStore.can(`write:pages`)">
<w-btn <w-btn
class="acrylic-btn ml-4" class="acrylic-btn ms-4"
flat flat
icon="la:edit" icon="la:edit"
color="deep-orange-9" color="deep-orange-9"
@ -244,7 +255,7 @@
--> -->
<template v-else-if="!editorStore.isActive && pageStore.canSuggestEdits && !isRedirect"> <template v-else-if="!editorStore.isActive && pageStore.canSuggestEdits && !isRedirect">
<w-btn <w-btn
class="acrylic-btn ml-4" class="acrylic-btn ms-4"
flat flat
icon="la:edit" icon="la:edit"
color="deep-orange-9" color="deep-orange-9"
@ -263,7 +274,7 @@
</template> </template>
<template v-if="editorStore.isActive || editorStore.hasPendingChanges"> <template v-if="editorStore.isActive || editorStore.hasPendingChanges">
<w-btn <w-btn
class="acrylic-btn ml-2" class="acrylic-btn ms-2"
flat flat
icon="la:times" icon="la:times"
color="negative" color="negative"
@ -276,7 +287,7 @@
no-caps no-caps
@click="discardChanges" /> @click="discardChanges" />
<w-btn <w-btn
class="acrylic-btn ml-2" class="acrylic-btn ms-2"
v-if="isSuggesting" v-if="isSuggesting"
flat flat
icon="la:paper-plane" icon="la:paper-plane"
@ -287,7 +298,7 @@
no-caps no-caps
@click="submitSuggestion" /> @click="submitSuggestion" />
<w-btn <w-btn
class="acrylic-btn ml-2" class="acrylic-btn ms-2"
v-else-if="editorStore.mode === `create`" v-else-if="editorStore.mode === `create`"
flat flat
icon="la:check" icon="la:check"
@ -296,7 +307,7 @@
:aria-label="t(`editor.createPage`)" :aria-label="t(`editor.createPage`)"
no-caps no-caps
@click="createPage" /> @click="createPage" />
<w-btn-group class="ml-2" v-else flat> <w-btn-group class="ms-2" v-else flat>
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
flat flat
@ -346,6 +357,7 @@ import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen' import { useMinWidth } from '@/composables/screen'
import { useCommonStore } from '@/stores/common'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { useFlagsStore } from '@/stores/flags' import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
@ -364,6 +376,7 @@ const BELL_RING_MS = 700
// STORES // STORES
const commonStore = useCommonStore()
const editorStore = useEditorStore() const editorStore = useEditorStore()
const flagsStore = useFlagsStore() const flagsStore = useFlagsStore()
const pageStore = usePageStore() const pageStore = usePageStore()
@ -397,6 +410,18 @@ const isPhoneViewport = computed(() => !isAtLeastSm.value)
*/ */
const iconSize = computed(() => (isPhoneViewport.value ? '32px' : '64px')) const iconSize = computed(() => (isPhoneViewport.value ? '32px' : '64px'))
/** The direction the page's own locale is written in, for its title and description. */
const contentDir = computed(() => siteStore.localeDir(pageStore.locale))
/**
* The physical side the row ends on, which is the side the action buttons are near and so the side a
* menu hanging off one of them aligns to. `WMenu` places in physical terms, being positioned against
* the window rather than laid out in the row.
*/
const endSide = computed(() =>
siteStore.localeDir(commonStore.locale) === 'rtl' ? 'left' : 'right'
)
/** /**
* Whether this row holds an editor's own controls — Save, Discard, Submit — rather than only the * Whether this row holds an editor's own controls — Save, Discard, Submit — rather than only the
* actions offered to a reader. Those must survive the phone layout: the properties panel puts the * actions offered to a reader. Those must survive the phone layout: the properties panel puts the

@ -16,7 +16,7 @@
@remove="removeTag(tag)" @remove="removeTag(tag)"
v-for="tag of pageStore.tags" v-for="tag of pageStore.tags"
:key="`tag-` + tag"> :key="`tag-` + tag">
<w-icon class="mr-1" name="la:hashtag" size="14px" /> <w-icon class="me-1" name="la:hashtag" size="14px" />
<span class="text-caption">{{ tag }}</span> <span class="text-caption">{{ tag }}</span>
</w-chip> </w-chip>
</div> </div>

@ -71,8 +71,8 @@ const props = defineProps({
default: null default: null
}, },
/** /**
* Whether the rail turns out to the left at each end to meet a border drawn down the column's left * Whether the rail turns out at each end to meet a border drawn down the column's starting edge,
* edge, rather than stopping level with the first and last label. Only the caller knows whether * rather than stopping level with the first and last label. Only the caller knows whether
* there is such a border to meet, and how far off it is: see `--page-toc-reach` in the stylesheet. * there is such a border to meet, and how far off it is: see `--page-toc-reach` in the stylesheet.
*/ */
joined: { joined: {
@ -329,8 +329,9 @@ onBeforeUnmount(() => {
/* /*
The contents list. The contents list.
Everything hangs off one vertical rail at the left: depth is indentation from it, and the heading Everything hangs off one vertical rail on the side the labels start from -- the left, or the right
being read marks it. Colours come from CSS custom properties rather than the SCSS palette so that on a right-to-left page, which is why every offset below is logical: depth is indentation from it,
and the heading being read marks it. Colours come from CSS custom properties rather than the SCSS palette so that
`--color-primary` follows a re-themed site, which a compiled `$primary` could not. `--color-primary` follows a re-themed site, which a compiled `$primary` could not.
*/ */
.page-toc { .page-toc {
@ -343,16 +344,16 @@ onBeforeUnmount(() => {
--page-toc-ink-hover: #{$grey-10}; --page-toc-ink-hover: #{$grey-10};
--page-toc-hover-surface: rgba(0, 0, 0, 0.04); --page-toc-hover-surface: rgba(0, 0, 0, 0.04);
/* /*
The space between the rail and a top-level label. The list is pulled left by the same amount, so The space between the rail and a top-level label. The list is pulled outwards by the same amount, so
the labels start on this component's own left edge -- level with whatever else the caller lines up the labels start on this component's own starting edge -- level with whatever else the caller lines up
there -- and the rail hangs out into the caller's padding. there -- and the rail hangs out into the caller's padding.
*/ */
--page-toc-gutter: 9px; --page-toc-gutter: 9px;
/* How far left of the rail the border a joined rail meets is: what is left of the caller's `px-4` */ /* How far out past the rail the border a joined rail meets is: what is left of the caller's `px-4` */
--page-toc-reach: calc(1rem - var(--page-toc-gutter)); --page-toc-reach: calc(1rem - var(--page-toc-gutter));
/* The radius of a joined rail's two turns, a little inside the reach so each keeps a short straight */ /* The radius of a joined rail's two turns, a little inside the reach so each keeps a short straight */
--page-toc-turn: 6px; --page-toc-turn: 6px;
/* The active marker's thickness, measured left from the rail's right-hand edge */ /* The active marker's thickness, measured outwards from the rail's inner edge */
--page-toc-marker-w: 4px; --page-toc-marker-w: 4px;
/* /*
What a joined rail encloses between itself and the border: half the article's own white, so the What a joined rail encloses between itself and the border: half the article's own white, so the
@ -375,7 +376,8 @@ onBeforeUnmount(() => {
&-list { &-list {
position: relative; position: relative;
margin: 0 0 0 calc(-1 * var(--page-toc-gutter)); margin: 0;
margin-inline-start: calc(-1 * var(--page-toc-gutter));
padding: 0; padding: 0;
list-style: none; list-style: none;
@ -385,19 +387,19 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
top: 3px; top: 3px;
bottom: 3px; bottom: 3px;
left: 0; inset-inline-start: 0;
width: 1px; width: 1px;
background-color: var(--page-toc-rail); background-color: var(--page-toc-rail);
} }
} }
/* /*
The joined rail: in from the column's left border above the list, a quarter turn down, the rail, The joined rail: in from the column's starting border above the list, a quarter turn down, the rail,
and a quarter turn back out to the border below it -- the same line round the contents that the and a quarter turn back out to the border below it -- the same line round the contents that the
history timeline draws round its entries. history timeline draws round its entries.
As there, all three stretches are ONE border of ONE box -- the top, right and bottom edges of an As there, all three stretches are ONE border of ONE box -- the top, inner and bottom edges of an
invisible rectangle whose left edge is the column's -- so that the straights and the turns cannot invisible rectangle whose outer edge is the column's -- so that the straights and the turns cannot
come out at different thicknesses under fractional display scaling. come out at different thicknesses under fractional display scaling.
The turns sit outside the list, above and below it, so the whole height of the list is the The turns sit outside the list, above and below it, so the whole height of the list is the
@ -413,26 +415,28 @@ onBeforeUnmount(() => {
*/ */
top: calc(-1 * max(var(--page-toc-lead, 0px), var(--page-toc-turn))); top: calc(-1 * max(var(--page-toc-lead, 0px), var(--page-toc-turn)));
bottom: calc(-1 * var(--page-toc-turn)); bottom: calc(-1 * var(--page-toc-turn));
left: calc(-1 * var(--page-toc-reach)); inset-inline-start: calc(-1 * var(--page-toc-reach));
box-sizing: border-box; box-sizing: border-box;
/* -> Its right-hand border lands where the plain rail is, on the list's own left edge */ /* -> Its inner border lands where the plain rail is, on the list's own starting edge */
width: calc(var(--page-toc-reach) + 1px); width: calc(var(--page-toc-reach) + 1px);
border: 1px solid var(--page-chrome-rule, var(--page-toc-rail)); border: 1px solid var(--page-chrome-rule, var(--page-toc-rail));
border-left: 0; border-inline-start: 0;
border-radius: 0 var(--page-toc-turn) var(--page-toc-turn) 0; border-radius: 0;
border-start-end-radius: var(--page-toc-turn);
border-end-end-radius: var(--page-toc-turn);
background-color: var(--page-toc-reach-fill); background-color: var(--page-toc-reach-fill);
} }
&-item { &-item {
position: relative; position: relative;
/* Depth is carried as a custom property by the template, so one rule indents every level */ /* Depth is carried as a custom property by the template, so one rule indents every level */
padding-left: calc(var(--page-toc-depth) * var(--page-toc-indent)); padding-inline-start: calc(var(--page-toc-depth) * var(--page-toc-indent));
} }
/* /*
The active marker, drawn ON the rail rather than beside it: the rail is the list's first pixel, The active marker, drawn ON the rail rather than beside it: the rail is the list's first pixel,
so every depth marks the same line whatever its indentation. Its right edge is the rail's right so every depth marks the same line whatever its indentation. Its inner edge is the rail's inner
edge, and the extra width grows out to the left -- toward the border a joined rail meets, and away edge, and the extra width grows outwards -- toward the border a joined rail meets, and away
from the labels, which then sit the same distance from it as from the rail. from the labels, which then sit the same distance from it as from the rail.
One element for the whole list rather than a pseudo on the active row, so that moving to the next One element for the whole list rather than a pseudo on the active row, so that moving to the next
@ -444,12 +448,14 @@ onBeforeUnmount(() => {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
/* -> The rail is 1px wide from `left: 0` */ /* -> The rail is 1px wide from the starting edge */
left: calc(1px - var(--page-toc-marker-w)); inset-inline-start: calc(1px - var(--page-toc-marker-w));
width: var(--page-toc-marker-w); width: var(--page-toc-marker-w);
height: var(--page-toc-marker-h, 0); height: var(--page-toc-marker-h, 0);
/* -> Square on the rail's side, so it sits flush along the line it marks */ /* -> Square on the rail's side, so it sits flush along the line it marks */
border-radius: calc(var(--page-toc-marker-w) / 2) 0 0 calc(var(--page-toc-marker-w) / 2); border-radius: 0;
border-start-start-radius: calc(var(--page-toc-marker-w) / 2);
border-end-start-radius: calc(var(--page-toc-marker-w) / 2);
background-color: var(--color-primary); background-color: var(--color-primary);
opacity: var(--page-toc-marker-opacity, 0); opacity: var(--page-toc-marker-opacity, 0);
transform: translateY(var(--page-toc-marker-y, 0)); transform: translateY(var(--page-toc-marker-y, 0));
@ -465,8 +471,9 @@ onBeforeUnmount(() => {
&-link { &-link {
display: block; display: block;
/* A gutter, not a caret column: the rail is the only thing to the left of a label */ /* A gutter, not a caret column: the rail is the only thing before a label */
padding: 3px 8px 3px var(--page-toc-gutter); padding-block: 3px;
padding-inline: var(--page-toc-gutter) 8px;
border-radius: 4px; border-radius: 4px;
color: inherit; color: inherit;
font-size: inherit; font-size: inherit;

@ -163,7 +163,7 @@ function onKeydown(ev) {
around it -- it is the lid of the column rather than something placed in it. around it -- it is the lid of the column rather than something placed in it.
Flat, in the contents column's own grey (`.page-sidebar` in `_page-chrome.scss`: `$grey-2` light, Flat, in the contents column's own grey (`.page-sidebar` in `_page-chrome.scss`: `$grey-2` light,
`$dark-5` dark). The two meet along the article's right-hand edge, so one value across both reads as `$dark-5` dark). The two meet along the article's edge beside that column, so one value across both reads as
a single piece of chrome bent round the top and the side of the column -- which is what a gradient a single piece of chrome bent round the top and the side of the column -- which is what a gradient
could not do, matching the column beside it at one height and missing it everywhere else. could not do, matching the column beside it at one height and missing it everywhere else.
@ -222,14 +222,14 @@ function onKeydown(ev) {
left: 0; left: 0;
/* /*
`--page-chrome-rule` is the edge this line belongs to, declared per theme on `.page-container` `--page-chrome-rule` is the edge this line belongs to, declared per theme on `.page-container`
(`_page-chrome.scss`): the same value continues down the right-hand side of the article as (`_page-chrome.scss`): the same value continues down the side of the article as
`.page-article-col`, so the two are stated once and turn the corner together. `.page-article-col`, so the two are stated once and turn the corner together.
*/ */
border-bottom: 1px solid var(--page-chrome-rule); border-bottom: 1px solid var(--page-chrome-rule);
} }
/* /*
The corner, through the 1px gap above. The article column's right-hand rule starts under that gap The corner, through the 1px gap above. The article column's side rule starts under that gap
and this line ends over it, so the one pixel where they turn the corner was left undrawn -- a and this line ends over it, so the one pixel where they turn the corner was left undrawn -- a
notch in what is meant to read as a single line. Only over the article column: the editor takes notch in what is meant to read as a single line. Only over the article column: the editor takes
its place under the strip too, and has no rule to join. its place under the strip too, and has no rule to join.
@ -242,6 +242,12 @@ function onKeydown(ev) {
width: 1px; width: 1px;
height: 1px; height: 1px;
background-color: var(--page-chrome-rule); background-color: var(--page-chrome-rule);
/* -> The edge is down the left when the contents column is (`.page-article-col`), so the corner is too */
.page-container--toc-left & {
right: auto;
left: 0;
}
} }
@at-root .body--light & { @at-root .body--light & {

@ -9,11 +9,8 @@
<w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps> <w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps>
<w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left"> <w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left">
<!-- <!--
The editors that author an ARTICLE, which is what a home page is -- so a blog and a The text editors a site has switched on -- see `HOME_EDITORS` for why this is not all
redirection are not offered, and nor is anything the site has switched off. of `siteStore.articleEditors`.
`siteStore.articleEditors` is the same answer the New Page menu draws its top group
from and the same one a blog's New Post button uses, which is what keeps the three
screens showing one set rather than three lists that drift.
The wording is this screen's own (`welcome.createHome<Editor>`, "Using the Markdown The wording is this screen's own (`welcome.createHome<Editor>`, "Using the Markdown
Editor" rather than "New Markdown Page"), because here the choice is how to write the Editor" rather than "New Markdown Page"), because here the choice is how to write the
@ -21,7 +18,7 @@
--> -->
<w-list padding> <w-list padding>
<w-item <w-item
v-for="editor of siteStore.articleEditors" v-for="editor of homeEditors"
:key="editor" :key="editor"
clickable clickable
@click="createHomePage(editor)"> @click="createHomePage(editor)">
@ -51,6 +48,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -80,6 +78,24 @@ const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
// CONSTANTS
/**
* The editors a wiki's first page may be written with.
*
* A home page is an article, but not every article editor is a sensible first page: a drawing is
* a canvas with no text for the home page to open on. So this is the text editors only, kept in
* the order `siteStore.articleEditors` gives them and still minus whatever the site has switched
* off, which is why it filters that list rather than replacing it.
*/
const HOME_EDITORS = ['markdown', 'visual', 'asciidoc']
// COMPUTED
const homeEditors = computed(() =>
siteStore.articleEditors.filter((editor) => HOME_EDITORS.includes(editor))
)
// META // META
useMeta(() => ({ useMeta(() => ({
@ -92,10 +108,9 @@ useMeta(() => ({
* The locale key naming an editor on this screen. * The locale key naming an editor on this screen.
* *
* Composed rather than tabulated, the same way the admin area names an editor * Composed rather than tabulated, the same way the admin area names an editor
* (`admin.editors.<id>Name`) and the search filter reads it back. The three that exist — * (`admin.editors.<id>Name`) and the search filter reads it back. One string per entry in
* `createHomeMarkdown`, `createHomeVisual`, `createHomeAsciidoc` — are keyed by the editor id with * `HOME_EDITORS` — `createHomeMarkdown`, `createHomeVisual`, `createHomeAsciidoc` — keyed by the
* its first letter raised, so an article editor added later needs a string of its own here or the * editor id with its first letter raised, so adding an editor there needs a string of its own too.
* menu shows the key.
*/ */
function labelFor(editor) { function labelFor(editor) {
return `welcome.createHome${capitalize(editor)}` return `welcome.createHome${capitalize(editor)}`

@ -43,7 +43,8 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
/** Pins the badge to the top-right of the nearest positioned ancestor. */ /** Pins the badge to the top end corner of the nearest positioned ancestor -- the top right, or the
* top left on a right-to-left interface. */
floating: { floating: {
type: Boolean, type: Boolean,
default: false default: false
@ -53,7 +54,7 @@ const props = defineProps({
const classes = computed(() => [ const classes = computed(() => [
props.rounded ? 'rounded-full' : 'rounded-sm', props.rounded ? 'rounded-full' : 'rounded-sm',
props.outline ? 'border border-current bg-transparent' : '', props.outline ? 'border border-current bg-transparent' : '',
props.floating ? 'absolute top-0 right-0 translate-x-1/2 -translate-y-1/3' : '' props.floating ? 'w-badge--floating absolute top-0 end-0' : ''
]) ])
const styles = computed(() => const styles = computed(() =>
@ -65,3 +66,20 @@ const styles = computed(() =>
} }
) )
</script> </script>
<style scoped>
/*
Half out past the corner it is pinned to, which is outwards to the right, or to the left on a
right-to-left interface.
An attribute test and not Tailwind's `rtl:` variant: that is written as `:dir(rtl)`, which the build
lowers to a list of `:lang()` selectors -- a guess from the language, where what decides is the
direction the element was given.
*/
.w-badge--floating {
translate: 50% -33.333%;
}
[dir='rtl'] .w-badge--floating {
translate: -50% -33.333%;
}
</style>

@ -48,8 +48,7 @@ defineProps({
/* -> After the two size rules, so it overrides whichever of them is in play */ /* -> After the two size rules, so it overrides whichever of them is in play */
.w-bar--flush { .w-bar--flush {
padding-left: 0; padding-inline: 0;
padding-right: 0;
} }
/* /*
@ -65,6 +64,6 @@ defineProps({
} }
.w-bar :deep(> * + *) { .w-bar :deep(> * + *) {
margin-left: 2px; margin-inline-start: 2px;
} }
</style> </style>

@ -13,7 +13,7 @@
v-if="item.icon" v-if="item.icon"
:name="item.icon" :name="item.icon"
class="w-breadcrumbs__el-icon" class="w-breadcrumbs__el-icon"
:class="item.label ? 'mr-2' : ''" /> :class="item.label ? 'me-2' : ''" />
<span v-if="item.label">{{ item.label }}</span> <span v-if="item.label">{{ item.label }}</span>
<w-tooltip v-if="item.tooltip">{{ item.tooltip }}</w-tooltip> <w-tooltip v-if="item.tooltip">{{ item.tooltip }}</w-tooltip>
</component> </component>

@ -28,23 +28,24 @@ defineProps({
`:deep` because the buttons come from the consumer's slot content and carry that component's `:deep` because the buttons come from the consumer's slot content and carry that component's
scope attribute rather than this one's. scope attribute rather than this one's.
*/ */
/* -> Logical sides throughout, so a group read right to left joins its buttons the same way */
.w-btn-group :deep(> .w-btn:not(:first-child)) { .w-btn-group :deep(> .w-btn:not(:first-child)) {
border-top-left-radius: 0; border-start-start-radius: 0;
border-bottom-left-radius: 0; border-end-start-radius: 0;
} }
.w-btn-group :deep(> .w-btn:not(:last-child)) { .w-btn-group :deep(> .w-btn:not(:last-child)) {
border-top-right-radius: 0; border-start-end-radius: 0;
border-bottom-right-radius: 0; border-end-end-radius: 0;
/* /*
The seam is a border rather than an inset shadow so that `unelevated` -- which is about the The seam is a border rather than an inset shadow so that `unelevated` -- which is about the
drop shadow -- can switch the one off without also erasing the other. drop shadow -- can switch the one off without also erasing the other.
*/ */
border-right: 1px solid rgb(0 0 0 / 0.12); border-inline-end: 1px solid rgb(0 0 0 / 0.12);
} }
:global(body.body--dark .w-btn-group > .w-btn:not(:last-child)) { :global(body.body--dark .w-btn-group > .w-btn:not(:last-child)) {
border-right-color: rgb(255 255 255 / 0.15); border-inline-end-color: rgb(255 255 255 / 0.15);
} }
.w-btn-group--unelevated :deep(> .w-btn) { .w-btn-group--unelevated :deep(> .w-btn) {

@ -143,8 +143,9 @@ const bundled = computed(() => {
*/ */
const colorClass = computed(() => [ const colorClass = computed(() => [
props.color ? `text-${props.color}` : '', props.color ? `text-${props.color}` : '',
props.left ? 'mr-2' : '', // -> Logical, so "before the text" and "after it" stay true on a right-to-left interface
props.right ? 'ml-2' : '' props.left ? 'me-2' : '',
props.right ? 'ms-2' : ''
]) ])
/** /**

@ -61,19 +61,19 @@ const classes = computed(() => [
*/ */
.w-item-section--side { .w-item-section--side {
align-items: flex-start; align-items: flex-start;
padding-right: 16px; padding-inline-end: 16px;
} }
/* A side section that FOLLOWS the main one is trailing: padding and alignment flip. */ /* A side section that FOLLOWS the main one is trailing: padding and alignment flip. */
.w-item-section--main ~ .w-item-section--side { .w-item-section--main ~ .w-item-section--side {
align-items: flex-end; align-items: flex-end;
padding-left: 16px; padding-inline-start: 16px;
padding-right: 0; padding-inline-end: 0;
} }
/* Two adjacent main sections get a small gutter, as they did before. */ /* Two adjacent main sections get a small gutter, as they did before. */
.w-item-section--main + .w-item-section--main { .w-item-section--main + .w-item-section--main {
margin-left: 8px; margin-inline-start: 8px;
} }
.w-item-section--avatar { .w-item-section--avatar {

@ -11,7 +11,7 @@
:key="i" :key="i"
:ref="(el) => setContainer(el, i)" :ref="(el) => setContainer(el, i)"
class="w-rating__icon-container flex h-[1em] items-center justify-center outline-0" class="w-rating__icon-container flex h-[1em] items-center justify-center outline-0"
:class="[editable ? 'cursor-pointer' : '', i > 1 ? 'ml-0.5' : '']" :class="[editable ? 'cursor-pointer' : '', i > 1 ? 'ms-0.5' : '']"
role="radio" role="radio"
:tabindex="editable ? 0 : -1" :tabindex="editable ? 0 : -1"
:aria-checked="modelValue === i ? 'true' : 'false'" :aria-checked="modelValue === i ? 'true' : 'false'"
@ -148,15 +148,19 @@ function set(value) {
} }
function onKeyup(e, i) { function onKeyup(e, i) {
// -> The stars run the way the text does, so on a right-to-left page the next one is to the LEFT
const isRtl = getComputedStyle(e.currentTarget).direction === 'rtl'
const back = isRtl ? 'ArrowRight' : 'ArrowLeft'
const forward = isRtl ? 'ArrowLeft' : 'ArrowRight'
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
set(i) set(i)
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { } else if (e.key === back || e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
containers.value?.[i - 2]?.focus() containers.value?.[i - 2]?.focus()
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { } else if (e.key === forward || e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
containers.value?.[i]?.focus() containers.value?.[i]?.focus()

@ -14,7 +14,7 @@
:for="selectId" :for="selectId"
class="mb-1 block text-caption text-black/60 dark:text-white/70"> class="mb-1 block text-caption text-black/60 dark:text-white/70">
{{ label }} {{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span> <span v-if="required" class="text-negative pe-1" aria-hidden="true">&nbsp;*</span>
</label> </label>
<!-- <!--
@ -38,7 +38,7 @@
!useInput && isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined !useInput && isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined
" "
:disabled="useInput ? undefined : isDisabled" :disabled="useInput ? undefined : isDisabled"
class="w-unstyled w-input-control flex w-full flex-nowrap items-center gap-2 rounded text-left" class="w-unstyled w-input-control flex w-full flex-nowrap items-center gap-2 rounded text-start"
:class="controlClasses" :class="controlClasses"
:style="controlStyle" :style="controlStyle"
@click="onControlClick" @click="onControlClick"
@ -54,7 +54,7 @@
<legend :class="isFloating ? 'w-input-outline-notch--open' : ''"> <legend :class="isFloating ? 'w-input-outline-notch--open' : ''">
<span <span
>{{ label >{{ label
}}<span v-if="required" class="text-negative pr-1" aria-hidden="true" }}<span v-if="required" class="text-negative pe-1" aria-hidden="true"
>&nbsp;*</span >&nbsp;*</span
></span ></span
> >
@ -73,7 +73,7 @@
class="w-input-float" class="w-input-float"
:class="[isFloating ? 'w-input-float--up' : '', floatColorClass]"> :class="[isFloating ? 'w-input-float--up' : '', floatColorClass]">
{{ label }} {{ label }}
<span v-if="required" class="text-negative pr-1" aria-hidden="true">&nbsp;*</span> <span v-if="required" class="text-negative pe-1" aria-hidden="true">&nbsp;*</span>
</span> </span>
<slot name="prepend" /> <slot name="prepend" />
@ -159,7 +159,7 @@
:key="idx" :key="idx"
role="option" role="option"
:aria-selected="String(isSelected(opt.value))" :aria-selected="String(isSelected(opt.value))"
class="w-select-option flex w-full cursor-pointer flex-nowrap items-center gap-2 px-4 text-left hover:bg-black/5 dark:hover:bg-white/8" class="w-select-option flex w-full cursor-pointer flex-nowrap items-center gap-2 px-4 text-start hover:bg-black/5 dark:hover:bg-white/8"
:class="[ :class="[
optionsDense ? 'min-h-8 py-1 text-body2' : 'min-h-10 py-2', optionsDense ? 'min-h-8 py-1 text-body2' : 'min-h-10 py-2',
isSelected(opt.value) ? selectedOptionClass : '', isSelected(opt.value) ? selectedOptionClass : '',

@ -370,7 +370,7 @@ $toc-overlay-max: 749.98px;
} }
/* /*
The article column's own right-hand edge, which is the strip's rule turned the corner: the strip The article column's own edge, which is the strip's rule turned the corner: the strip
closes the top of the column, this closes its side, and the two meet at the strip's bottom corner so closes the top of the column, this closes its side, and the two meet at the strip's bottom corner so
that what is drawn reads as one line round the content rather than as two borders that happen to that what is drawn reads as one line round the content rather than as two borders that happen to
touch. touch.
@ -382,9 +382,20 @@ $toc-overlay-max: 749.98px;
Worn whether or not there is a strip above it. What is on the other side is the contents column or Worn whether or not there is a strip above it. What is on the other side is the contents column or
the actions rail, both of them chrome, and a column edge that appeared only on pages that take the actions rail, both of them chrome, and a column edge that appeared only on pages that take
comments would be a difference a reader cannot account for. comments would be a difference a reader cannot account for.
On the right, against the contents column or the rail -- or on the left when the contents are a
column there (`page-container--toc-left`, from `pages/Index.vue`), since the edge is drawn against
the contents wherever they are. Physical sides both, because which side the column is on is the
theme's setting and not the language's. `PageViewTabs.vue` turns the corner to meet it on the same
class.
*/ */
.page-article-col { .page-article-col {
border-right: 1px solid var(--page-chrome-rule); border-right: 1px solid var(--page-chrome-rule);
.page-container--toc-left & {
border-right: 0;
border-left: 1px solid var(--page-chrome-rule);
}
} }
.page-sidebar { .page-sidebar {

@ -130,7 +130,7 @@
*/ */
caption.title { caption.title {
padding: 0.5em 0.8em; padding: 0.5em 0.8em;
text-align: left; text-align: start;
} }
/* -- Boxed blocks ------------------------------------------------------------ */ /* -- Boxed blocks ------------------------------------------------------------ */
@ -153,7 +153,7 @@
} }
.sidebarblock { .sidebarblock {
border-left: 4px solid var(--content-rule-strong); border-inline-start: 4px solid var(--content-rule-strong);
} }
/* -> The panel's padding is the margin of what it holds, as it is at the top level */ /* -> The panel's padding is the margin of what it holds, as it is at the top level */
@ -213,11 +213,14 @@
Asciidoctor emits it as a `pre`, so the only thing it needs is to stop looking like code. Asciidoctor emits it as a `pre`, so the only thing it needs is to stop looking like code.
*/ */
.verseblock pre { .verseblock pre {
/* -> Prose, so it takes the page's direction back from the one every code `pre` is pinned to */
direction: inherit;
margin: 1.5em 0; margin: 1.5em 0;
padding: 0 0 0 var(--code-pad-x); padding: 0;
/* -> `border` before `border-left`, so the box `_page-contents.scss` gives every `pre` goes */ padding-inline-start: var(--code-pad-x);
/* -> `border` before `border-inline-start`, so the box `_page-contents.scss` gives every `pre` goes */
border: 0; border: 0;
border-left: 3px solid var(--content-rule); border-inline-start: 3px solid var(--content-rule);
background: none; background: none;
color: var(--content-ink-muted); color: var(--content-ink-muted);
font-family: inherit; font-family: inherit;
@ -241,20 +244,20 @@
font-size: 0.9375em; font-size: 0.9375em;
ol { ol {
padding-left: 0; padding-inline-start: 0;
list-style: none; list-style: none;
counter-reset: wiki-callout; counter-reset: wiki-callout;
} }
li { li {
position: relative; position: relative;
padding-left: 2em; padding-inline-start: 2em;
counter-increment: wiki-callout; counter-increment: wiki-callout;
&::before { &::before {
content: counter(wiki-callout); content: counter(wiki-callout);
position: absolute; position: absolute;
left: 0; inset-inline-start: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -401,7 +404,7 @@
/* -> `[grid=none]` and `[frame=none]`, which are a request to draw the data without the ruling */ /* -> `[grid=none]` and `[frame=none]`, which are a request to draw the data without the ruling */
table.grid-none > * > tr > * { table.grid-none > * > tr > * {
border-right: 0; border-inline-end: 0;
border-bottom: 0; border-bottom: 0;
} }
table.frame-none { table.frame-none {

@ -418,15 +418,32 @@
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
right: 0; inset-inline-end: 0;
bottom: 0; bottom: 0;
/* -> Only a top-level title reaches past the text column; see the override below */ /* -> Only a top-level title reaches past the text column; see the override below */
left: 0; inset-inline-start: 0;
height: var(--content-heading-rule-weight); height: var(--content-heading-rule-weight);
background-image: linear-gradient(to right, var(--content-heading-rule-color), transparent); background-image: linear-gradient(to right, var(--content-heading-rule-color), transparent);
} }
} }
/*
Fading out towards the end of the line, which on a right-to-left page is the left -- a gradient has
no logical direction to say that with.
An attribute test rather than `:dir(rtl)`, which the build lowers to a list of `:lang()` selectors:
a guess from the language, where what decides is the direction the element was given. Both shapes,
because the attribute is on this element in the page view and on the one around it in the visual
editor, where ProseMirror owns the element carrying the class.
*/
&[dir='rtl'],
[dir='rtl'] & {
h1::after,
h2::after {
background-image: linear-gradient(to left, var(--content-heading-rule-color), transparent);
}
}
h1 { h1 {
--content-heading-rule-weight: 2px; --content-heading-rule-weight: 2px;
--content-heading-rule-color: var(--content-h1-rule); --content-heading-rule-color: var(--content-h1-rule);
@ -479,7 +496,7 @@
in a quote or a list item. in a quote or a list item.
*/ */
> h1::after { > h1::after {
left: calc(-1 * var(--content-bleed)); inset-inline-start: calc(-1 * var(--content-bleed));
} }
h3 { h3 {
@ -509,7 +526,7 @@
h6[data-heading-anchor] { h6[data-heading-anchor] {
position: relative; position: relative;
/* -> In `rem` to match the button it reserves room for, which no longer scales with the heading */ /* -> In `rem` to match the button it reserves room for, which no longer scales with the heading */
padding-right: 2.25rem; padding-inline-end: 2.25rem;
} }
.heading-anchor { .heading-anchor {
@ -521,7 +538,7 @@
they follow the glyph's size rather than the heading's. they follow the glyph's size rather than the heading's.
*/ */
top: 50%; top: 50%;
right: 0; inset-inline-end: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -567,7 +584,7 @@
content: attr(data-tooltip); content: attr(data-tooltip);
position: absolute; position: absolute;
top: 50%; top: 50%;
right: calc(100% + 4px); inset-inline-end: calc(100% + 4px);
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 4px; border-radius: 4px;
background-color: rgb(0 0 0 / 0.85); background-color: rgb(0 0 0 / 0.85);
@ -708,7 +725,7 @@
/* -> Sized in `em`, so it keeps its proportion to the words wherever the link sits */ /* -> Sized in `em`, so it keeps its proportion to the words wherever the link sits */
width: 0.8em; width: 0.8em;
height: 0.8em; height: 0.8em;
margin-left: 0.25em; margin-inline-start: 0.25em;
background-color: currentColor; background-color: currentColor;
/* -> Subordinate to the words: a marker, not a second link */ /* -> Subordinate to the words: a marker, not a second link */
opacity: 0.7; opacity: 0.7;
@ -731,7 +748,7 @@
ul, ul,
ol { ol {
margin: 0 0 1.15em; margin: 0 0 1.15em;
padding-left: 1.6em; padding-inline-start: 1.6em;
} }
ul { ul {
@ -787,11 +804,11 @@
icon font, which is what the treatment this replaces depended on. icon font, which is what the treatment this replaces depended on.
*/ */
.contains-task-list { .contains-task-list {
padding-left: 0; padding-inline-start: 0;
list-style: none; list-style: none;
.contains-task-list { .contains-task-list {
padding-left: 1.6em; padding-inline-start: 1.6em;
} }
} }
@ -873,17 +890,17 @@
*/ */
ul.links-list { ul.links-list {
margin: 1.5em 0; margin: 1.5em 0;
padding-left: 0; padding-inline-start: 0;
list-style: none; list-style: none;
> li { > li {
margin-top: 4px; margin-top: 4px;
border-radius: 4px; border-radius: 4px;
/* /*
The bar down the left, grey at rest and the link's colour under the pointer -- so what hovering The bar down the start edge, grey at rest and the link's colour under the pointer -- so what hovering
does is colour something already there rather than add a bar and shunt the words sideways. does is colour something already there rather than add a bar and shunt the words sideways.
*/ */
border-left: 3px solid var(--content-rule); border-inline-start: 3px solid var(--content-rule);
background-color: var(--content-links-row); background-color: var(--content-links-row);
/* -> The light down the top of the row; see `--content-links-sheen` */ /* -> The light down the top of the row; see `--content-links-sheen` */
background-image: linear-gradient(to bottom, var(--content-links-sheen), transparent 65%); background-image: linear-gradient(to bottom, var(--content-links-sheen), transparent 65%);
@ -894,7 +911,7 @@
&:hover, &:hover,
&:focus-within { &:focus-within {
border-left-color: var(--content-link); border-inline-start-color: var(--content-link);
background-color: var(--content-links-row-hover); background-color: var(--content-links-row-hover);
} }
} }
@ -928,9 +945,9 @@
a line of small print. a line of small print.
*/ */
em { em {
margin-left: 0.5em; margin-inline-start: 0.5em;
padding-left: 0.75em; padding-inline-start: 0.75em;
border-left: 1px solid var(--content-rule); border-inline-start: 1px solid var(--content-rule);
color: var(--content-ink-muted); color: var(--content-ink-muted);
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
@ -942,9 +959,9 @@
*/ */
@media (max-width: $breakpoint-xs-max) { @media (max-width: $breakpoint-xs-max) {
em { em {
margin-left: 0; margin-inline-start: 0;
padding-left: 0; padding-inline-start: 0;
border-left: 0; border-inline-start: 0;
font-size: 0.9375em; font-size: 0.9375em;
} }
@ -998,12 +1015,12 @@
> ol, > ol,
> ul { > ul {
/* /*
Half a disc of indent, so the discs hang off the left edge of the text column the way a Half a disc of indent, so the discs hang off the start edge of the text column the way a
marker does, and the step's own content lines up close to where a list's would (1.7rem marker does, and the step's own content lines up close to where a list's would (1.7rem
against a list's 1.6em). The markers themselves go: the number is on the disc. against a list's 1.6em). The markers themselves go: the number is on the disc.
*/ */
margin-left: calc(var(--content-step-size) / 2); margin-inline-start: calc(var(--content-step-size) / 2);
padding-left: 0; padding-inline-start: 0;
list-style: none; list-style: none;
counter-reset: wiki-step; counter-reset: wiki-step;
} }
@ -1019,7 +1036,7 @@
*/ */
margin-top: 0; margin-top: 0;
padding-bottom: 1.4em; padding-bottom: 1.4em;
padding-left: calc(var(--content-step-size) / 2 + 0.9rem); padding-inline-start: calc(var(--content-step-size) / 2 + 0.9rem);
counter-increment: wiki-step; counter-increment: wiki-step;
} }
@ -1034,7 +1051,7 @@
content: counter(wiki-step); content: counter(wiki-step);
position: absolute; position: absolute;
top: 0; top: 0;
left: calc(var(--content-step-size) / -2); inset-inline-start: calc(var(--content-step-size) / -2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -1076,7 +1093,7 @@
to be seen. to be seen.
A border rather than a background, so that it prints -- see the note on `h1::after` further A border rather than a background, so that it prints -- see the note on `h1::after` further
down: a print dialog leaves background graphics off by default. Half a hairline to the left, down: a print dialog leaves background graphics off by default. Half a hairline outwards,
which is what centres it under the discs above and below it. which is what centres it under the discs above and below it.
*/ */
> ol > li::after, > ol > li::after,
@ -1085,8 +1102,8 @@
position: absolute; position: absolute;
top: var(--content-step-size); top: var(--content-step-size);
bottom: 0; bottom: 0;
left: -0.5px; inset-inline-start: -0.5px;
border-left: 1px solid var(--content-step-line); border-inline-start: 1px solid var(--content-step-line);
} }
/* -> Nothing below the last step for a line to reach */ /* -> Nothing below the last step for a line to reach */
@ -1154,8 +1171,9 @@
*/ */
blockquote { blockquote {
margin: 1.5em 0; margin: 1.5em 0;
padding: 0.15em 0 0.15em 1.15em; padding-block: 0.15em;
border-left: 3px solid var(--content-rule-strong); padding-inline: 1.15em 0;
border-inline-start: 3px solid var(--content-rule-strong);
color: var(--content-ink-muted); color: var(--content-ink-muted);
> :last-child { > :last-child {
@ -1188,9 +1206,13 @@
&.is-danger, &.is-danger,
&:has(> .is-danger) { &:has(> .is-danger) {
position: relative; position: relative;
padding: 0.9em 1.1em 0.9em 3.1em; padding-block: 0.9em;
border-left-width: 4px; padding-inline: 3.1em 1.1em;
border-radius: 0 6px 6px 0; border-inline-start-width: 4px;
/* -> Square where the bar is, round on the far side */
border-radius: 6px;
border-start-start-radius: 0;
border-end-start-radius: 0;
color: var(--content-ink); color: var(--content-ink);
&::before { &::before {
@ -1210,7 +1232,7 @@
and `0.625em` half the icon. and `0.625em` half the icon.
*/ */
top: calc(0.9em + 0.79em - 0.625em); top: calc(0.9em + 0.79em - 0.625em);
left: 1.1em; inset-inline-start: 1.1em;
width: 1.25em; width: 1.25em;
height: 1.25em; height: 1.25em;
background-color: currentColor; background-color: currentColor;
@ -1243,7 +1265,7 @@
&:has(> .is-info) { &:has(> .is-info) {
--alert-hue: var(--content-info); --alert-hue: var(--content-info);
border-left-color: var(--content-info); border-inline-start-color: var(--content-info);
background-color: var(--content-info-wash); background-color: var(--content-info-wash);
&::before { &::before {
@ -1256,7 +1278,7 @@
&:has(> .is-success) { &:has(> .is-success) {
--alert-hue: var(--content-success); --alert-hue: var(--content-success);
border-left-color: var(--content-success); border-inline-start-color: var(--content-success);
background-color: var(--content-success-wash); background-color: var(--content-success-wash);
&::before { &::before {
@ -1270,7 +1292,7 @@
&:has(> .is-important) { &:has(> .is-important) {
--alert-hue: var(--content-important); --alert-hue: var(--content-important);
border-left-color: var(--content-important); border-inline-start-color: var(--content-important);
background-color: var(--content-important-wash); background-color: var(--content-important-wash);
&::before { &::before {
@ -1283,7 +1305,7 @@
&:has(> .is-warning) { &:has(> .is-warning) {
--alert-hue: var(--content-warning); --alert-hue: var(--content-warning);
border-left-color: var(--content-warning); border-inline-start-color: var(--content-warning);
background-color: var(--content-warning-wash); background-color: var(--content-warning-wash);
&::before { &::before {
@ -1296,7 +1318,7 @@
&:has(> .is-danger) { &:has(> .is-danger) {
--alert-hue: var(--content-danger); --alert-hue: var(--content-danger);
border-left-color: var(--content-danger); border-inline-start-color: var(--content-danger);
background-color: var(--content-danger-wash); background-color: var(--content-danger-wash);
&::before { &::before {
@ -1360,6 +1382,13 @@
/* -> Code does not wrap: a wrapped line is a different line, and indentation stops meaning anything */ /* -> Code does not wrap: a wrapped line is a different line, and indentation stops meaning anything */
overflow-x: auto; overflow-x: auto;
tab-size: 2; tab-size: 2;
/*
Left to right on a right-to-left page too: code is written that way in every language, and
mirrored, the indentation lands on the wrong side and the punctuation at the end of a line is
reordered to the start of it. Everything inside -- the gutter, the copy button, a highlighted
line's bleed -- is placed in physical terms on the strength of this.
*/
direction: ltr;
/* -> The panel is the block's own; the code inside it drops the inline span's wash and padding */ /* -> The panel is the block's own; the code inside it drops the inline span's wash and padding */
> code { > code {
@ -1573,6 +1602,8 @@
the bar's own wash is mixed from the ink it ends up with. the bar's own wash is mixed from the ink it ends up with.
*/ */
.codeblock-titled { .codeblock-titled {
/* -> The title is a file name, and reads with the code under it -- see `pre` */
direction: ltr;
margin: 1.5em 0; margin: 1.5em 0;
border: 1px solid var(--content-rule); border: 1px solid var(--content-rule);
border-radius: 8px; border-radius: 8px;
@ -1736,14 +1767,14 @@
th, th,
td { td {
padding: 0.5em 0.8em; padding: 0.5em 0.8em;
border-right: 1px solid var(--content-rule); border-inline-end: 1px solid var(--content-rule);
border-bottom: 1px solid var(--content-rule); border-bottom: 1px solid var(--content-rule);
text-align: left; text-align: start;
vertical-align: top; vertical-align: top;
} }
tr > :last-child { tr > :last-child {
border-right: 0; border-inline-end: 0;
} }
/* -> `tbody:last-child`, because a MultiMarkdown table can have several: only the bottom of the last /* -> `tbody:last-child`, because a MultiMarkdown table can have several: only the bottom of the last
@ -1790,7 +1821,7 @@
} }
thead th { thead th {
border-right-color: var(--content-table-head-rule); border-inline-end-color: var(--content-table-head-rule);
border-bottom-color: var(--content-table-head-rule); border-bottom-color: var(--content-table-head-rule);
background-color: transparent; background-color: transparent;
color: var(--content-table-head-ink); color: var(--content-table-head-ink);
@ -1854,7 +1885,7 @@
padding-bottom: 0.5em; padding-bottom: 0.5em;
color: var(--content-ink-muted); color: var(--content-ink-muted);
font-size: 0.875em; font-size: 0.875em;
text-align: left; text-align: start;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2056,7 +2087,7 @@
font-size: 0.875em; font-size: 0.875em;
.footnotes-list { .footnotes-list {
padding-left: 1.4em; padding-inline-start: 1.4em;
} }
.footnote-item > p { .footnote-item > p {
@ -2083,7 +2114,7 @@
} }
.footnote-backref { .footnote-backref {
margin-left: 0.3em; margin-inline-start: 0.3em;
text-decoration: none; text-decoration: none;
} }
@ -2127,8 +2158,8 @@
*/ */
/* /*
`0px` and not `0`: the title's rule positions itself with `calc(-1 * var(--content-bleed))`, and `0px` and not `0`: the title's rule positions itself with `calc(-1 * var(--content-bleed))`, and
a unitless zero makes that a NUMBER rather than a length. The declaration is then invalid, `left` a unitless zero makes that a NUMBER rather than a length. The declaration is then invalid, the
falls back to `auto` against a `right: 0`, and a rule with no width to shrink to disappears -- start inset falls back to `auto` against an end one of `0`, and a rule with no width to shrink to disappears --
which is exactly how the page title lost the rule under it on paper. which is exactly how the page title lost the rule under it on paper.
*/ */
--content-bleed: 0px; --content-bleed: 0px;
@ -2271,7 +2302,7 @@
Dark grey rather than the near-black the table's head uses on screen: it is enough for white to Dark grey rather than the near-black the table's head uses on screen: it is enough for white to
read on (about 9:1) and costs a third less ink over a bar that repeats once per panel. read on (about 9:1) and costs a third less ink over a bar that repeats once per panel.
The bar's negative left margin is what takes it back over the padding so that it meets the spine The bar's negative start margin is what takes it back over the padding so that it meets the spine
rather than starting inboard of it. rather than starting inboard of it.
`print-color-adjust` is what makes any of it appear, for the reason a done task item carries the `print-color-adjust` is what makes any of it appear, for the reason a done task item carries the
@ -2290,15 +2321,16 @@
*/ */
--content-tab-pad: 0.75rem; --content-tab-pad: 0.75rem;
border-left: 4px solid var(--content-tab-bar); border-inline-start: 4px solid var(--content-tab-bar);
padding-left: var(--content-tab-pad); padding-inline-start: var(--content-tab-pad);
print-color-adjust: exact; print-color-adjust: exact;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
} }
block-tab[label]::before { block-tab[label]::before {
content: attr(label); content: attr(label);
display: block; display: block;
margin: 0 0 0.9em calc(-1 * var(--content-tab-pad)); margin-block: 0 0.9em;
margin-inline: calc(-1 * var(--content-tab-pad)) 0;
padding: 0.35em var(--content-tab-pad); padding: 0.35em var(--content-tab-pad);
background-color: var(--content-tab-bar); background-color: var(--content-tab-bar);
color: #fff; color: #fff;
@ -2331,7 +2363,7 @@
display: inline; display: inline;
width: auto; width: auto;
height: auto; height: auto;
margin-left: 0; margin-inline-start: 0;
background-color: transparent; background-color: transparent;
mask-image: none; mask-image: none;
opacity: 1; opacity: 1;

@ -3,8 +3,15 @@
<w-header class="site-header-wrap"> <w-header class="site-header-wrap">
<header-nav /> <header-nav />
</w-header> </w-header>
<!--
In the interface's language and direction. Which edge of the window the drawer sits on is the
theme's `sidebarPosition` and is placed in physical terms, so it does not move with them -- what
runs the other way on a right-to-left interface is what is inside it.
-->
<w-drawer <w-drawer
class="bg-sidebar" class="bg-sidebar"
:lang="commonStore.locale"
:dir="siteStore.localeDir(commonStore.locale)"
v-model="isSidebarOpen" v-model="isSidebarOpen"
:width="sidebarWidth" :width="sidebarWidth"
:overlay-below="SIDEBAR_OVERLAY_BELOW" :overlay-below="SIDEBAR_OVERLAY_BELOW"

@ -131,7 +131,12 @@
<w-item> <w-item>
<blueprint-icon :text="lc.language" /> <blueprint-icon :text="lc.language" />
<w-item-section> <w-item-section>
<w-item-label>{{ lc.name }}</w-item-label> <w-item-label>
{{ lc.name }}
<w-badge v-if="lc.isRTL" class="ml-1 align-middle" color="grey-7" rounded>
{{ t('admin.locale.rtl') }}
</w-badge>
</w-item-label>
<w-item-label caption>{{ lc.nativeName }} ({{ lc.displayCode }})</w-item-label> <w-item-label caption>{{ lc.nativeName }} ({{ lc.displayCode }})</w-item-label>
</w-item-section> </w-item-section>
<!-- <!--

@ -12,35 +12,55 @@
--> -->
<!-- -> `py-1` on a phone: with the date gone the bar holds one line of small type, and 8px above and <!-- -> `py-1` on a phone: with the date gone the bar holds one line of small type, and 8px above and
below it made a strip nearly as tall as the crumbs themselves --> below it made a strip nearly as tall as the crumbs themselves -->
<!-- -> Chrome, so in the interface's language and direction, as the header below it is -->
<div <div
class="page-breadcrumbs py-1 px-4 sm:py-2 flex flex-wrap" class="page-breadcrumbs py-1 px-4 sm:py-2 flex flex-wrap"
:lang="commonStore.locale"
:dir="siteStore.localeDir(commonStore.locale)"
v-if="!editorStore.isActive && !pageStore.notFound"> v-if="!editorStore.isActive && !pageStore.notFound">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<w-breadcrumbs <w-breadcrumbs
:items="breadcrumbs" :items="breadcrumbs"
:active-color="dark.isActive ? `grey-5` : `grey-7`" :active-color="dark.isActive ? `grey-5` : `grey-7`"
separator-color="grey"> separator-color="grey">
<template #separator><w-icon name="la:angle-right" /></template> <template #separator><w-icon :name="crumbSeparator" /></template>
</w-breadcrumbs> </w-breadcrumbs>
</div> </div>
<!-- <div class="flex-none flex items-center justify-end gap-3">
Off on a phone: on a 390px screen the date takes a whole line of its own under the trail, which <!--
is a lot of room for something a reader is not here for -- and the trail itself is how they get Beside the date because both say what state the page is in, where the header's row is what
back out, so that is what the bar keeps. can be done with it. Whoever is looking at a draft can already see it, so the badge is not
--> gated on being logged in the way those actions are: it is telling a reader what they are
<div class="flex-none items-center justify-end hidden sm:flex"> reading, not offering them something to do.
<template v-if="!pageStore.publishState === `draft`">
<div class="text-caption text-accent"><strong>Unpublished</strong></div> Kept on a phone, unlike the date: it is one short word, and it changes how everything under it
<w-separator class="mx-2" vertical /> should be read.
</template> -->
<div class="text-caption text-grey-6"> <w-badge
Last modified on <strong>{{ lastModified }}</strong> v-if="pageStore.publishState === `draft`"
class="uppercase"
color="negative"
:label="t(`editor.props.draft`)" />
<!--
Off on a phone: on a 390px screen the date takes a whole line of its own under the trail, which
is a lot of room for something a reader is not here for -- and the trail itself is how they get
back out, so that is what the bar keeps.
-->
<div class="text-caption text-grey-6 hidden sm:block">
<i18n-t keypath="common.page.lastModified">
<template #date>
<strong>{{ lastModified }}</strong>
</template>
</i18n-t>
</div> </div>
</div> </div>
</div> </div>
<page-header v-if="!pageStore.notFound" /> <page-header v-if="!pageStore.notFound" />
<!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll --> <!-- -> `min-h-0` so the columns inside can be shorter than their content and scroll -->
<div class="page-container flex min-h-0 flex-nowrap items-stretch" style="flex: 1 1 100%"> <div
class="page-container flex min-h-0 flex-nowrap items-stretch"
:class="{ 'page-container--toc-left': tocColumnIsLeft }"
style="flex: 1 1 100%">
<!-- <!--
`flex flex-col min-h-0`: the strip below is fixed to the top of this column and the scrolling `flex flex-col min-h-0`: the strip below is fixed to the top of this column and the scrolling
article takes what is left, which is what keeps a tab bar at the top of the view rather than article takes what is left, which is what keeps a tab bar at the top of the view rather than
@ -193,9 +213,12 @@
Delegated rather than bound per link: the anchors are written by `v-html`, so there is Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render. nothing here to put a handler on, and they are replaced wholesale on every render.
--> -->
<!-- -> Written in the page's own language and direction, which need not be the interface's -->
<div <div
class="page-contents" class="page-contents"
:class="{ 'is-asciidoc': pageStore.editor === `asciidoc` }" :class="{ 'is-asciidoc': pageStore.editor === `asciidoc` }"
:lang="pageStore.locale"
:dir="siteStore.localeDir(pageStore.locale)"
ref="pageContents" ref="pageContents"
v-show="activeView === `article` && !isBlog" v-show="activeView === `article` && !isBlog"
v-html="pageStore.render" v-html="pageStore.render"
@ -302,10 +325,17 @@
somewhere (a heading, a tag), and a panel left over the place they were going would have to be somewhere (a heading, a tag), and a panel left over the place they were going would have to be
dismissed by hand. A `<button>` in here -- the tag editor's, the rating -- is not that, which is dismissed by hand. A `<button>` in here -- the tag editor's, the rating -- is not that, which is
why the test is `closest('a')` rather than any click at all. why the test is `closest('a')` rather than any click at all.
In the page's own language and direction, labels included: on a page the interface speaks the
page's locale (see `App.vue`), so the headings, the tags and the captions beside them are all
written in it. Which side of the article the column sits on, and where the panel slides in from,
is the layout's business and is set in physical terms, so neither moves with it.
--> -->
<div <div
class="page-sidebar" class="page-sidebar"
v-if="showSidebar" v-if="showSidebar"
:lang="pageStore.locale"
:dir="siteStore.localeDir(pageStore.locale)"
:class="{ 'is-open': tocPanelIsOpen }" :class="{ 'is-open': tocPanelIsOpen }"
:style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`" :style="siteStore.theme.tocPosition === `left` ? `order: 1;` : `order: 2;`"
@click="onSidebarClick"> @click="onSidebarClick">
@ -321,7 +351,7 @@
<template v-if="showToc"> <template v-if="showToc">
<!-- TOC --> <!-- TOC -->
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:stream" color="grey" /> <w-icon class="me-2" name="la:stream" color="grey" />
<!-- -> Its own string, not `common.page.toc`: this heading labels a column beside the <!-- -> Its own string, not `common.page.toc`: this heading labels a column beside the
article and reads better short, where "Table of Contents" is the full name of the article and reads better short, where "Table of Contents" is the full name of the
thing and belongs where there is room for it --> thing and belongs where there is room for it -->
@ -344,7 +374,7 @@
@mouseover="state.showTagsEditBtn = true" @mouseover="state.showTagsEditBtn = true"
@mouseleave="state.showTagsEditBtn = false"> @mouseleave="state.showTagsEditBtn = false">
<div class="flex items-center"> <div class="flex items-center">
<w-icon class="mr-2" name="la:tags" color="grey" /> <w-icon class="me-2" name="la:tags" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.tags') }}</div>
<w-space /> <w-space />
<!-- <!--
@ -381,7 +411,7 @@
<w-separator v-if="showToc || showTags" /> <w-separator v-if="showToc || showTags" />
<!-- Rating --> <!-- Rating -->
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:star-half-alt" color="grey" /> <w-icon class="me-2" name="la:star-half-alt" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.ratePage') }}</div>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
@ -432,7 +462,7 @@
<template v-if="showLastEditedBy"> <template v-if="showLastEditedBy">
<w-separator v-if="showToc || showTags || showRatings" /> <w-separator v-if="showToc || showTags || showRatings" />
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:user-edit" color="grey" /> <w-icon class="me-2" name="la:user-edit" color="grey" />
<div class="text-caption text-grey-7">{{ t('common.page.lastEditedBy') }}</div> <div class="text-caption text-grey-7">{{ t('common.page.lastEditedBy') }}</div>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
@ -444,7 +474,7 @@
alt="" /> alt="" />
<span v-else>{{ lastEditorInitial }}</span> <span v-else>{{ lastEditorInitial }}</span>
</w-avatar> </w-avatar>
<span class="ml-2">{{ pageStore.authorName }}</span> <span class="ms-2">{{ pageStore.authorName }}</span>
</router-link> </router-link>
</div> </div>
</template> </template>
@ -691,14 +721,34 @@ const tocPanelIsOpen = computed(() => tocIsPanel.value && showSidebar.value && s
const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen) const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value && !state.tocPanelOpen)
/* /*
Whether the contents rail runs out to meet the article column's right-hand edge (`.page-article-col`), Whether the contents are a column to the LEFT of the article, which is what moves the article
which is only beside it while the contents are a column to the RIGHT of the article. On the left the column's edge (`.page-article-col`) over to that side: the edge is drawn against the contents, and
edge is on the article's far side, and the panel has a shadow rather than an edge to meet. with the contents on the left the right-hand side of the article is the actions rail.
Only while there is such a column. With nothing to show in it, or below 750px where it is a panel
over the article instead, what is on the left is the nav sidebar or the window, and the edge stays on
the right against the rail.
*/ */
const tocJoinsArticleEdge = computed( const tocColumnIsLeft = computed(
() => !tocIsPanel.value && siteStore.theme.tocPosition !== 'left' () => showSidebar.value && !tocIsPanel.value && siteStore.theme.tocPosition === 'left'
) )
/*
Whether the contents rail runs out to meet the article column's edge -- which is only beside it while
the contents are a column, and the panel has a shadow rather than an edge to meet.
The rail is on the side the labels start from, so it meets the edge only where that is the side facing
the article: a column on the right read left to right, or one on the left read right to left. In the
other two the rail is on the column's far side and there is nothing for it to turn out and meet.
*/
const tocJoinsArticleEdge = computed(() => {
if (tocIsPanel.value) {
return false
}
const isRtl = siteStore.localeDir(pageStore.locale) === 'rtl'
return siteStore.theme.tocPosition === 'left' ? isRtl : !isRtl
})
/** /**
* Whether the page on screen is a blog's front page, which is drawn as its posts rather than as an * Whether the page on screen is a blog's front page, which is drawn as its posts rather than as an
* article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this. * article -- see `PageBlog.vue`. A blog POST is an ordinary page and is not this.
@ -953,8 +1003,9 @@ const relationsRight = computed(() => {
}) })
const lastModified = computed(() => { const lastModified = computed(() => {
return pageStore.updatedAt return pageStore.updatedAt
? // -> The fields luxon's DATETIME_MED expanded to, so the bar reads exactly as before ? // -> The fields luxon's DATETIME_MED expanded to, so the bar reads exactly as before -- and in
Temporal.Instant.from(pageStore.updatedAt).toLocaleString(undefined, { // the interface's language, since it is set inside a sentence written in it
Temporal.Instant.from(pageStore.updatedAt).toLocaleString(locale.value, {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -964,15 +1015,20 @@ const lastModified = computed(() => {
: 'N/A' : 'N/A'
}) })
/**
* The trail the breadcrumb bar draws, root first. The Home crumb is prepended here rather than
* written into the markup, so the bar takes a single flat list.
*/
/** The blog this page is a post of, as a route on this site. Null-safe: the line is `v-if`'d on it. */ /** The blog this page is a post of, as a route on this site. Null-safe: the line is `v-if`'d on it. */
const blogHref = computed( const blogHref = computed(
() => `${siteStore.localeUrlPrefix(pageStore.locale)}/${pageStore.blog?.path ?? ''}` () => `${siteStore.localeUrlPrefix(pageStore.locale)}/${pageStore.blog?.path ?? ''}`
) )
/** The chevron between two crumbs points down the trail, which right to left is to the left. */
const crumbSeparator = computed(() =>
siteStore.localeDir(commonStore.locale) === 'rtl' ? 'la:angle-left' : 'la:angle-right'
)
/**
* The trail the breadcrumb bar draws, root first. The Home crumb is prepended here rather than
* written into the markup, so the bar takes a single flat list.
*/
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
{ key: 'home', icon: 'la:home', to: '/', ariaLabel: 'Home', tooltip: 'Home' }, { key: 'home', icon: 'la:home', to: '/', ariaLabel: 'Home', tooltip: 'Home' },
...pageStore.breadcrumbs.map((brd) => ({ ...pageStore.breadcrumbs.map((brd) => ({

@ -88,6 +88,8 @@
<div <div
class="page-contents" class="page-contents"
:class="{ 'is-asciidoc': state.version?.meta?.editor === `asciidoc` }" :class="{ 'is-asciidoc': state.version?.meta?.editor === `asciidoc` }"
:lang="state.version?.pageLocale"
:dir="siteStore.localeDir(state.version?.pageLocale)"
ref="pageContents" ref="pageContents"
v-html="state.render" v-html="state.render"
@click="onContentClick" /> @click="onContentClick" />

@ -208,6 +208,15 @@ export const useSiteStore = defineStore('site', {
/** How a locale is referred to — `fr` for `fr-FR` — falling back to the code until read. */ /** How a locale is referred to — `fr` for `fr-FR` — falling back to the code until read. */
localeAlias: (state) => (code) => localeAlias: (state) => (code) =>
state.installedLocales.find((lc) => lc.code === code)?.displayCode ?? code, state.installedLocales.find((lc) => lc.code === code)?.displayCode ?? code,
/**
* The `dir` a locale's text is written in, for the elements that hold a page's own content.
*
* Asked of the PAGE's locale, not of the interface's: an Arabic page on a wiki read in English is
* still read right to left, while the chrome around it stays the way the interface language runs.
* A locale the wiki does not have installed reads as `ltr`, since nothing says otherwise.
*/
localeDir: (state) => (code) =>
state.installedLocales.find((lc) => lc.code === code)?.isRTL ? 'rtl' : 'ltr',
/** /**
* The segments a locale-prefixed URL may start with, mapped to the locale each names. * The segments a locale-prefixed URL may start with, mapped to the locale each names.
* *

Loading…
Cancel
Save