clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

feat: add declarative folder layout

Dami committed a97e932
commit
a97e9327e2bd8ce608c53f9393eb317c10b6e76e
parent
fbd9122ac0fe0f89f347e5650482b722d8a17fa2
author
Dami <dami.etoile@gmail.com>
6 files changed, +389 -90
MREADME.md6+-
Mcrates/devsite-cli/src/main.rs7+-
Mcrates/devsite-server/src/api.rs24+-
Mcrates/devsite-server/src/theme.rs322++++++++++++++++++++++++++----
Mdocs/profile-template.md58++++--
Mweb/app.js62+++++-
M README.md +4 -2
M crates/devsite-cli/src/main.rs +4 -3
M crates/devsite-server/src/api.rs +12 -12
@@ -284,15 +284,17 @@ pub struct ProfileResponse {
284284 pub handle: String,285285 pub entries: Vec<ProfileEntry>,286286 pub shared_with_me: Vec<ProfileEntry>,287- /// The owner's theme: validated `--pico-*` assignments, in the order they288- /// were written. The page turns these into one rule scoped to the profile.287+ /// The owner's presentation: validated `--pico-*` theme assignments and288+ /// `--devsite-*` layout settings, in the order they were written. The page289+ /// turns only the Pico declarations into a scoped rule.289290 pub theme: Vec<theme::Declaration>,290291 }291292 292293 #[derive(Deserialize)]293294 pub struct ThemeRequest {294- /// Declarations only — `--pico-border-radius: 0.5rem;` and so on. Anything295- /// that is not on the whitelist is a 400 with a reason, never a silent drop.295+ /// Declarations only — `--pico-border-radius: 0.5rem;`,296+ /// `--devsite-folders: closed;`, and so on. Anything outside the whitelist297+ /// is a 400 with a reason, never a silent drop.296298 pub css: String,297299 }298300
@@ -673,9 +675,6 @@ fn resolve_share_accounts(
673675 Ok(viewers)674676 }675677 676-/// Longest folder name. It is a label on a fold, not a description.677-const MAX_FOLDER: usize = 40;678-679678 /// Check a folder name, treating blank as none.680679 ///681680 /// A folder has no existence of its own — it is this string, repeated across the
@@ -685,9 +684,10 @@ fn validate_folder(folder: Option<&str>) -> ApiResult<Option<String>> {
685684 let Some(folder) = folder.map(str::trim).filter(|f| !f.is_empty()) else {686685 return Ok(None);687686 };688- if folder.chars().count() > MAX_FOLDER {687+ if folder.chars().count() > theme::MAX_FOLDER_NAME {689688 return Err(ApiError::bad_request(format!(690- "a folder name may be at most {MAX_FOLDER} characters"689+ "a folder name may be at most {} characters",690+ theme::MAX_FOLDER_NAME691691 )));692692 }693693 // Control characters would survive HTML-escaping and come out as invisible
@@ -995,7 +995,7 @@ async fn revoke_machine_credential(
995995 996996 // -- themes --------------------------------------------------------------------997997 998-/// The properties a theme may set, and what each one accepts.998+/// The presentation properties a profile may set, and what each one accepts.999999 ///10001000 /// Served by the same binary that enforces the list, so the CLI, the website and10011001 /// the docs cannot drift from what is actually accepted.
@@ -1449,8 +1449,8 @@ mod tests {
14491449 14501450 #[test]14511451 fn folder_names_are_bounded_and_printable() {1452- assert!(validate_folder(Some(&"x".repeat(MAX_FOLDER))).is_ok());1453- assert!(validate_folder(Some(&"x".repeat(MAX_FOLDER + 1))).is_err());1452+ assert!(validate_folder(Some(&"x".repeat(theme::MAX_FOLDER_NAME))).is_ok());1453+ assert!(validate_folder(Some(&"x".repeat(theme::MAX_FOLDER_NAME + 1))).is_err());14541454 assert!(validate_folder(Some("Games\u{0}")).is_err());14551455 assert!(validate_folder(Some("two\nlines")).is_err());14561456 }
M crates/devsite-server/src/theme.rs +276 -46
@@ -1,8 +1,9 @@
1-//! User themes.1+//! User profile presentation.22 //!3-//! A profile's look is not a stylesheet. It is a list of assignments to named4-//! Pico variables, each with a declared value grammar — so "is this theme5-//! valid?" is a question with a mechanical answer rather than a judgement call.3+//! A profile's presentation is not a stylesheet. It is a list of assignments to4+//! named Pico variables and dev.site layout settings, each with a declared value5+//! grammar — so "is this valid?" is a question with a mechanical answer rather6+//! than a judgement call.67 //!78 //! Two things follow from that, and both are the point:89 //!
@@ -10,21 +11,28 @@
1011 //! `!important`. A theme can only recolour and re-space what the profile1112 //! template already lays out; it cannot position, hide, or overlay anything.1213 //! - **It cannot inject.** Every property name is one of the `&'static str`s in13-//! [`PROPERTIES`], and every value has passed a grammar whose alphabet is14-//! `[0-9a-z#%.,/()+- ]`. Neither can carry `<`, `"` or `}`, so the emitted15-//! rule is safe to inline in a `<style>` element without further escaping.14+//! [`PROPERTIES`]. Pico values have passed a restricted CSS-value grammar;15+//! quoted folder names have passed a bounded JSON-string grammar and never16+//! enter the generated `<style>` rule at all.1617 //!17-//! The variables below all exist in the vendored Pico 2.1.1. A name that Pico18-//! does not define would be accepted, stored, and do nothing — which is exactly19-//! the silent failure this list is here to prevent.18+//! Every `--pico-*` variable below exists in the vendored Pico 2.1.1; every19+//! `--devsite-*` setting has an explicit renderer consumer. An unknown name is20+//! rejected instead of being stored and quietly doing nothing.2021 2122 use serde::Serialize;2223 23-/// Longest accepted source, before parsing. A theme is a few dozen short lines.24+/// Longest accepted source, before parsing. Presentation is a few dozen short lines.2425 pub const MAX_INPUT: usize = 4096;2526 /// Longest accepted value. This leaves room for two functional colours inside2627 /// `light-dark()` without changing the overall [`MAX_INPUT`] bound.2728 pub const MAX_VALUE: usize = 128;29+/// Folder names are labels, not descriptions. Count Unicode scalar values so30+/// non-ASCII text remains valid while the stored and rendered label stays small.31+pub const MAX_FOLDER_NAME: usize = 40;32+/// A layout list remains well below the whole-document limit even when it names33+/// many folders. This also bounds the work done by the browser renderer.34+const MAX_LAYOUT_FOLDERS: usize = 64;35+const MAX_LAYOUT_VALUE: usize = 3072;2836 2937 /// One validated assignment. `property` is borrowed from [`PROPERTIES`], so a3038 /// stored declaration cannot name anything outside the table.
@@ -45,17 +53,25 @@ enum Kind {
4553 Number,4654 /// One of a fixed set of words.4755 Keyword(&'static [&'static str]),56+ /// One or more JSON-style quoted folder names, separated by commas.57+ FolderNames,4858 }4959 5060 /// The one key that is not a Pico variable: it chooses which of Pico's own5161 /// palettes the profile starts from, by setting `data-theme` on the page.5262 pub const SCHEME: &str = "--devsite-scheme";63+pub const FOLDERS: &str = "--devsite-folders";64+pub const OPEN_FOLDERS: &str = "--devsite-open-folders";65+pub const FOLDER_ORDER: &str = "--devsite-folder-order";5366 54-/// Every property a theme may set, with the grammar of its value.67+/// Every profile presentation property, with the grammar of its value.5568 ///56-/// Ordered as it is documented: scheme, then colour, then metrics, then type.69+/// Ordered as documented: dev.site controls, then colour, metrics, and type.5770 const PROPERTIES: &[(&str, Kind)] = &[5871 (SCHEME, Kind::Keyword(&["light", "dark", "auto"])),72+ (FOLDERS, Kind::Keyword(&["open", "closed"])),73+ (OPEN_FOLDERS, Kind::FolderNames),74+ (FOLDER_ORDER, Kind::FolderNames),5975 // -- surfaces and text ---------------------------------------------------6076 ("--pico-background-color", Kind::Color),6177 ("--pico-color", Kind::Color),
@@ -144,18 +160,18 @@ pub fn parse(input: &str) -> Result<Vec<Declaration>, String> {
144160 let source = strip_comments(input)?;145161 let mut declarations: Vec<Declaration> = Vec::new();146162 147- for chunk in source.split(';') {163+ for chunk in split_declarations(&source) {148164 let chunk = chunk.trim();149165 if chunk.is_empty() {150166 continue;151167 }152- if let Some(bad) = chunk.chars().find(|c| "{}@<>\"'\\".contains(*c)) {168+ if let Some(bad) = find_outside_quotes(chunk, |c| "{}@<>'\\".contains(c)) {153169 return Err(format!(154- "`{bad}` is not allowed: a theme is a list of `--pico-…: value;` \155- declarations, not a stylesheet"170+ "`{bad}` is not allowed: a profile is a list of validated \171+ `--pico-…` and `--devsite-…` declarations, not a stylesheet"156172 ));157173 }158- if chunk.contains('!') {174+ if find_outside_quotes(chunk, |c| c == '!').is_some() {159175 return Err(160176 "`!important` is not allowed; a theme never has to out-rank anything".into(),161177 );
@@ -165,23 +181,20 @@ pub fn parse(input: &str) -> Result<Vec<Declaration>, String> {
165181 .split_once(':')166182 .ok_or_else(|| format!("`{chunk}` is missing a `:`"))?;167183 let name = name.trim().to_ascii_lowercase();168- let value = normalize_whitespace(value);184+ let raw_value = value.trim();169185 170- if value.is_empty() {186+ if raw_value.is_empty() {171187 return Err(format!("`{name}` has no value"));172188 }173- if value.len() > MAX_VALUE {174- return Err(format!("the value of `{name}` is too long"));175- }176189 177190 let (property, kind) = PROPERTIES178191 .iter()179192 .find(|(known, _)| *known == name)180193 .ok_or_else(|| unknown_property(&name))?;181194 182- if !kind.accepts(&value) {183- return Err(format!("`{name}: {value}` — expected {}", kind.describe()));184- }195+ let value = kind196+ .canonicalize(raw_value)197+ .ok_or_else(|| format!("`{name}: {raw_value}` — expected {}", kind.describe()))?;185198 186199 // Last one wins, as it would in a real rule block, but the theme keeps187200 // the order it was written in. Collapsing duplicates here is also what
@@ -204,7 +217,7 @@ pub fn to_css(declarations: &[Declaration]) -> String {
204217 .collect()205218 }206219 207-/// Every property a theme may set, for `devsite theme properties` and the docs.220+/// Every profile presentation property, for `devsite theme properties` and the docs.208221 pub fn properties() -> impl Iterator<Item = (&'static str, String)> {209222 PROPERTIES210223 .iter()
@@ -214,31 +227,54 @@ pub fn properties() -> impl Iterator<Item = (&'static str, String)> {
214227 fn unknown_property(name: &str) -> String {215228 // A near miss is nearly always a typo, or a real Pico variable that is not216229 // offered here, so point at the most specific thing that is.217- let stem = name.trim_start_matches("--pico-").trim_start_matches("--");230+ let stem = name231+ .trim_start_matches("--pico-")232+ .trim_start_matches("--devsite-")233+ .trim_start_matches("--");218234 let closest = PROPERTIES219235 .iter()220236 .map(|(known, _)| *known)221237 .filter(|known| {222- let known_stem = known.trim_start_matches("--pico-").trim_start_matches("--");238+ let known_stem = known239+ .trim_start_matches("--pico-")240+ .trim_start_matches("--devsite-")241+ .trim_start_matches("--");223242 stem.len() >= 4 && (stem.contains(known_stem) || known_stem.contains(stem))224243 })225244 .max_by_key(|known| known.len());226245 227246 match closest {228- Some(known) => format!("`{name}` is not a theme property — did you mean `{known}`?"),247+ Some(known) => format!("`{name}` is not a profile property — did you mean `{known}`?"),229248 None => {230- format!("`{name}` is not a theme property; run `devsite theme properties` for the list")249+ format!(250+ "`{name}` is not a profile property; run `devsite theme properties` for the list"251+ )231252 }232253 }233254 }234255 235256 impl Kind {236- fn accepts(&self, value: &str) -> bool {257+ fn canonicalize(&self, raw: &str) -> Option<String> {258+ if matches!(self, Kind::FolderNames) {259+ if raw.len() > MAX_LAYOUT_VALUE {260+ return None;261+ }262+ return parse_folder_names(raw);263+ }264+265+ let value = normalize_whitespace(raw);266+ if value.len() > MAX_VALUE {267+ return None;268+ }237269 match self {238- Kind::Color => is_color(value),239- Kind::Length => is_length(value),240- Kind::Number => is_number(value),241- Kind::Keyword(allowed) => allowed.contains(&value.to_ascii_lowercase().as_str()),270+ Kind::Color => is_color(&value).then_some(value),271+ Kind::Length => is_length(&value).then_some(value),272+ Kind::Number => is_number(&value).then_some(value),273+ Kind::Keyword(allowed) => {274+ let canonical = value.to_ascii_lowercase();275+ allowed.contains(&canonical.as_str()).then_some(canonical)276+ }277+ Kind::FolderNames => unreachable!(),242278 }243279 }244280
@@ -248,6 +284,9 @@ impl Kind {
248284 Kind::Length => "a length, e.g. `0.5rem`, `12px` or `0`".into(),249285 Kind::Number => "a number, e.g. `1.5`".into(),250286 Kind::Keyword(allowed) => format!("one of {}", allowed.join(", ")),287+ Kind::FolderNames => {288+ "one or more quoted folder names, e.g. `\"Profiles\", \"Services\"`".into()289+ }251290 }252291 }253292 }
@@ -345,21 +384,128 @@ fn is_number(value: &str) -> bool {
345384 && value.len() <= 8346385 }347386 387+fn parse_folder_names(value: &str) -> Option<String> {388+ let names: Vec<String> = serde_json::from_str(&format!("[{value}]")).ok()?;389+ if names.is_empty() || names.len() > MAX_LAYOUT_FOLDERS {390+ return None;391+ }392+393+ let mut seen = std::collections::HashSet::new();394+ for name in &names {395+ if name.is_empty()396+ || name.trim() != name397+ || name.chars().count() > MAX_FOLDER_NAME398+ || name.chars().any(char::is_control)399+ || !seen.insert(name)400+ {401+ return None;402+ }403+ }404+405+ Some(406+ names407+ .iter()408+ .map(|name| serde_json::to_string(name).expect("a string always serializes"))409+ .collect::<Vec<_>>()410+ .join(", "),411+ )412+}413+348414 fn strip_comments(input: &str) -> Result<String, String> {349415 let mut out = String::with_capacity(input.len());350- let mut rest = input;351- while let Some(start) = rest.find("/*") {352- out.push_str(&rest[..start]);353- let after = &rest[start + 2..];354- let end = after.find("*/").ok_or("a comment is not closed")?;355- // A comment separates tokens, so it cannot simply vanish.356- out.push(' ');357- rest = &after[end + 2..];358- }359- out.push_str(rest);416+ let mut chars = input.chars().peekable();417+ let mut quoted = false;418+ let mut escaped = false;419+420+ while let Some(character) = chars.next() {421+ if quoted {422+ out.push(character);423+ if escaped {424+ escaped = false;425+ } else if character == '\\' {426+ escaped = true;427+ } else if character == '"' {428+ quoted = false;429+ }430+ continue;431+ }432+433+ if character == '"' {434+ quoted = true;435+ out.push(character);436+ } else if character == '/' && chars.peek() == Some(&'*') {437+ chars.next();438+ let mut previous = '\0';439+ let mut closed = false;440+ for comment in chars.by_ref() {441+ if previous == '*' && comment == '/' {442+ closed = true;443+ break;444+ }445+ previous = comment;446+ }447+ if !closed {448+ return Err("a comment is not closed".into());449+ }450+ // A comment separates tokens, so it cannot simply vanish.451+ out.push(' ');452+ } else {453+ out.push(character);454+ }455+ }456+457+ if quoted {458+ return Err("a quoted folder name is not closed".into());459+ }360460 Ok(out)361461 }362462 463+fn split_declarations(input: &str) -> Vec<&str> {464+ let mut chunks = Vec::new();465+ let mut start = 0;466+ let mut quoted = false;467+ let mut escaped = false;468+ for (index, character) in input.char_indices() {469+ if quoted {470+ if escaped {471+ escaped = false;472+ } else if character == '\\' {473+ escaped = true;474+ } else if character == '"' {475+ quoted = false;476+ }477+ } else if character == '"' {478+ quoted = true;479+ } else if character == ';' {480+ chunks.push(&input[start..index]);481+ start = index + 1;482+ }483+ }484+ chunks.push(&input[start..]);485+ chunks486+}487+488+fn find_outside_quotes(input: &str, predicate: impl Fn(char) -> bool) -> Option<char> {489+ let mut quoted = false;490+ let mut escaped = false;491+ for character in input.chars() {492+ if quoted {493+ if escaped {494+ escaped = false;495+ } else if character == '\\' {496+ escaped = true;497+ } else if character == '"' {498+ quoted = false;499+ }500+ } else if character == '"' {501+ quoted = true;502+ } else if predicate(character) {503+ return Some(character);504+ }505+ }506+ None507+}508+363509 fn normalize_whitespace(value: &str) -> String {364510 value.split_whitespace().collect::<Vec<_>>().join(" ")365511 }
@@ -538,6 +684,75 @@ mod tests {
538684 );539685 }540686 687+ #[test]688+ fn accepts_and_canonicalizes_profile_folder_layout() {689+ let source = r#"690+ --devsite-folders: closed;691+ --devsite-open-folders: "Profiles";692+ --devsite-folder-order: "Profiles","Services", "Games";693+ "#;694+ let canonical = css(source).unwrap();695+ assert_eq!(696+ canonical,697+ "--devsite-folders: closed;\n\698+ --devsite-open-folders: \"Profiles\";\n\699+ --devsite-folder-order: \"Profiles\", \"Services\", \"Games\";\n"700+ );701+ assert_eq!(css(&canonical).unwrap(), canonical);702+ }703+704+ #[test]705+ fn folder_layout_preserves_bounded_unicode_and_json_escapes() {706+ let source = r#"--devsite-open-folders: "日本語 🧰", "say \"hi\" \\ path", "semi; /* literal */", "</style>";"#;707+ let canonical = css(source).unwrap();708+ assert_eq!(709+ canonical,710+ r#"--devsite-open-folders: "日本語 🧰", "say \"hi\" \\ path", "semi; /* literal */", "</style>";711+"#712+ );713+ assert_eq!(css(&canonical).unwrap(), canonical);714+ }715+716+ #[test]717+ fn folder_layout_rejects_invalid_or_unbounded_name_lists() {718+ let overlong = "x".repeat(MAX_FOLDER_NAME + 1);719+ let too_many = (0..=MAX_LAYOUT_FOLDERS)720+ .map(|index| format!("\"folder-{index}\""))721+ .collect::<Vec<_>>()722+ .join(", ");723+ for value in [724+ "Profiles".to_string(),725+ "\"\"".to_string(),726+ "\" Profiles\"".to_string(),727+ "\"Profiles \"".to_string(),728+ "\"Profiles\", \"Profiles\"".to_string(),729+ "\"Profiles\",".to_string(),730+ "[\"Profiles\"]".to_string(),731+ "42".to_string(),732+ "\"two\\nlines\"".to_string(),733+ format!("\"{overlong}\""),734+ too_many,735+ ] {736+ assert!(737+ parse(&format!("--devsite-open-folders: {value};")).is_err(),738+ "{value:?} should be refused"739+ );740+ }741+ }742+743+ #[test]744+ fn folder_default_accepts_only_open_or_closed() {745+ assert!(parse("--devsite-folders: open;").is_ok());746+ assert!(parse("--devsite-folders: closed;").is_ok());747+ assert_eq!(748+ css("--devsite-folders: CLOSED;").unwrap(),749+ "--devsite-folders: closed;\n"750+ );751+ for value in ["auto", "true", "none", "\"closed\""] {752+ assert!(parse(&format!("--devsite-folders: {value};")).is_err());753+ }754+ }755+541756 #[test]542757 fn accepts_every_documented_color_form() {543758 for value in [
@@ -786,6 +1001,21 @@ mod tests {
7861001 .bytes()7871002 .all(|b| b.is_ascii_alphanumeric() || b"#%.,/()+- ".contains(&b)));7881003 }1004+1005+ // Layout declarations may contain arbitrary printable folder labels,1006+ // but are distinguishable by name so the browser can keep them out of1007+ // the generated Pico rule.1008+ for declaration in parse(1009+ r#"--devsite-open-folders: "</style><script>alert(1)</script>"; --pico-primary: red;"#,1010+ )1011+ .unwrap()1012+ {1013+ if declaration.property.starts_with("--pico-") {1014+ assert!(!declaration.value.contains('<'));1015+ assert!(!declaration.value.contains('>'));1016+ assert!(!declaration.value.contains('"'));1017+ }1018+ }7891019 }7901020 7911021 #[test]
M docs/profile-template.md +38 -20
@@ -1,9 +1,10 @@
11 # The profile template22 33 A dev.site profile is one fixed piece of semantic HTML styled by [Pico CSS] and4-nothing else. Personalisation happens by assigning Pico's own variables, not by5-writing rules — which is what makes "is this theme valid?" a question the server6-can answer mechanically, in `crates/devsite-server/src/theme.rs`.4+nothing else. Personalisation happens through validated declarations, not rules:5+Pico variables control appearance, while three dev.site variables control the6+initial folder layout. That makes "is this profile declaration valid?" a question7+the server can answer mechanically in `crates/devsite-server/src/theme.rs`.78 89 [Pico CSS]: https://picocss.com910
@@ -13,7 +14,7 @@ can answer mechanically, in `crates/devsite-server/src/theme.rs`.
1314 | --- | --- | --- |1415 | Framework | `web/vendor/pico.min.css` | Pico 2.1.1, vendored. Styles semantic elements directly. |1516 | Site | `web/site.css` | The typeface, and the few layout rules Pico has no opinion about. No colours. |16-| Theme | `<style id="profile-theme">` | One rule, written at render time, holding only `--pico-*` assignments. |17+| Theme | `<style id="profile-theme">` | One rule, written at render time, holding only `--pico-*` assignments. `--devsite-*` layout values never enter it. |1718 1819 They load in that order, and the theme is written as one rule:1920
@@ -43,8 +44,8 @@ no `--pico-font-family` in the whitelist.
4344 4445 ## The template4546 46-Rendered by `web/app.js`. A theme is only meaningful against this structure, so47-changes here and changes to the whitelist belong in the same commit.47+Rendered by `web/app.js`. Profile declarations are only meaningful against this48+structure, so changes here and changes to the whitelist belong in the same commit.4849 4950 ```html5051 <body>
@@ -75,7 +76,8 @@ changes here and changes to the whitelist belong in the same commit.
7576 </li>7677 </ul>7778 78- <!-- then a fold per folder, in the order those first appear -->79+ <!-- then a fold per folder, in the owner's declared order followed by80+ unlisted folders in the order they first appear -->7981 <details class="folder" open>8082 <summary>Games <small>3</small></summary>8183 <ul class="entries">…</ul>
@@ -127,25 +129,35 @@ That falls out of the rendering too. The set of folders is derived from the
127129 entries the viewer is allowed to see, so a folder holding only private sites does128130 not appear to a stranger as an empty fold — it is never built at all.129131 130-Folds are `<details open>`. A profile's job is to show what is on it, so a folder131-groups without hiding, and collapsing is the reader's choice rather than the132-owner's. Pico draws the marker and handles the open state; `.folder > summary`133-is the label.132+Folds are semantic `<details>` elements. They are open by default for profiles133+without layout declarations. `--devsite-folders` changes that initial default,134+and `--devsite-open-folders` names exceptions that start open. Once rendered,135+the reader controls the ordinary open state. Pico draws the marker and handles136+the interaction; `.folder > summary` is the label.137+138+`--devsite-folder-order` names folders that should appear first. A name not139+visible to the current viewer is ignored; visible folders omitted from the list140+retain their first-appearance order. Folder names use JSON-style quoted strings,141+the same non-control Unicode accepted by resource folders, with the same142+40-scalar limit. Quotes and backslashes use JSON escapes.134143 135144 `--pico-ins-color` and `--pico-del-color` are still in the whitelist, and are136145 what the viewer's success and failure messages are drawn with.137146 138-## What a theme may set147+## What profile presentation may set139148 140149 The list lives in `PROPERTIES` in `theme.rs` and is served at141150 `GET /api/theme/properties`, which is also what `devsite theme properties`142-prints. Every name in it is a variable the vendored Pico actually defines; a name143-Pico does not define would be accepted, stored, and quietly do nothing, which is144-the failure mode the list exists to prevent.151+prints. Every `--pico-*` name in it is a variable the vendored Pico actually152+defines. The three folder-layout names are consumed explicitly by `app.js` and153+never emitted as CSS. An unrecognized name is rejected rather than accepted,154+stored, and left to do nothing.145155 146156 | Group | Properties | Value |147157 | --- | --- | --- |148158 | Scheme | `--devsite-scheme` | `light`, `dark` or `auto` |159+| Layout | `--devsite-folders` | `open` or `closed` |160+| | `--devsite-open-folders`, `--devsite-folder-order` | one or more quoted folder names |149161 | Surfaces | `--pico-background-color`, `--pico-color`, `--pico-muted-color`, `--pico-muted-border-color`, `--pico-border-color`, `--pico-text-selection-color` | colour |150162 | Accents | `--pico-primary` and its `-background`, `-hover`, `-hover-background`, `-inverse`, `-underline`, `-focus`; `--pico-secondary` and its `-background`, `-hover`, `-inverse`; `--pico-contrast` and its `-background`, `-inverse` | colour |151163 | Headings | `--pico-h1-color` … `--pico-h6-color` | colour |
@@ -168,6 +180,7 @@ leaves `data-theme` unset and follows the viewer's system preference. Explicit
168180 | Kind | Accepts |169181 | --- | --- |170182 | colour | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`; `rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()` over plain numbers; a CSS colour name; `transparent`; `currentcolor`; or `light-dark(<light-color>, <dark-color>)` containing exactly two of these colours |183+| folder list | one or more comma-separated JSON-style strings containing non-empty, trimmed, non-control folder names of at most 40 Unicode scalar values |171184 | length | a non-negative number with `px`, `rem`, `em`, `ch`, `ex`, `vw`, `vh`, `vmin`, `vmax` or `%`, or bare `0` |172185 | number | a non-negative unitless number |173186
@@ -200,9 +213,11 @@ Refused outright, with a message naming the reason:
200213 - Pico variables outside the list, including `--pico-font-family` and the201214 `--pico-icon-*` data URIs202215 203-So a theme can recolour and re-space the template, and that is all. It cannot204-position anything, hide anything, overlay anything, or load anything. A profile205-you visit cannot use its theme to disguise itself as another part of the site.216+Pico declarations can recolour and re-space the template, and that is all.217+Layout declarations may choose the initial open state and order of folders, but218+they cannot remove a visible entry or prevent a reader from opening a fold.219+Nothing can position, overlay, or load anything, so a profile you visit cannot220+use its presentation to disguise itself as another part of the site.206221 207222 ## Setting one208223
@@ -222,6 +237,9 @@ devsite theme clear
222237 ```css223238 /* my-theme.css */224239 --devsite-scheme: auto;240+--devsite-folders: closed;241+--devsite-open-folders: "Profiles";242+--devsite-folder-order: "Profiles", "Services", "Games";225243 --pico-background-color: light-dark(#fefae0, #283618);226244 --pico-color: light-dark(#283618, #fefae0);227245 --pico-primary: light-dark(#bc6c25, #dda15e);
@@ -237,8 +255,8 @@ Rejections are specific and name the offending declaration:
237255 238256 ```239257 --pico-primary: wine; → `--pico-primary: wine` — expected a colour, e.g. `#7b3fe4`, …240---pico-primary-color: → `--pico-primary-color` is not a theme property — did you mean `--pico-primary`?241-body { color: red } → `{` is not allowed: a theme is a list of `--pico-…: value;` declarations258+--pico-primary-color: → `--pico-primary-color` is not a profile property — did you mean `--pico-primary`?259+body { color: red } → `{` is not allowed: a profile is a list of validated declarations242260 ```243261 244262 ## Storage
M web/app.js +55 -7
@@ -149,7 +149,10 @@ function applyTheme(handle, declarations = []) {
149149 }150150 151151 const body = declarations152- .filter((d) => d.property !== '--devsite-scheme')152+ // Layout declarations are data for renderProfile, not CSS. Restricting the153+ // generated rule positively to Pico variables keeps quoted folder names out154+ // of the style element regardless of what printable Unicode they contain.155+ .filter((d) => d.property.startsWith('--pico-'))153156 .map((d) => ` ${d.property}: ${d.value};`)154157 .join('\n');155158 $('profile-theme').textContent = body
@@ -310,12 +313,56 @@ function foldersOf(entries) {
310313 return folders;311314 }312315 313-/// One fold. `<details open>` — a profile's job is to show what is on it, so a314-/// folder groups without hiding, and collapsing is the reader's choice.315-function folder(name, items) {316+/// Validated layout declarations arrive in canonical JSON-string-list form.317+/// Parsing them again here is assembly rather than trust: malformed values can318+/// only mean an older/newer server mismatch, in which case the safe default is319+/// the original all-open, first-appearance layout.320+function profileLayout(declarations = []) {321+ const value = (property) =>322+ declarations.find((declaration) => declaration.property === property)?.value;323+ const names = (property) => {324+ const list = value(property);325+ if (!list) return [];326+ try {327+ const parsed = JSON.parse(`[${list}]`);328+ return Array.isArray(parsed) && parsed.every((name) => typeof name === 'string')329+ ? parsed330+ : [];331+ } catch {332+ return [];333+ }334+ };335+ return {336+ foldersOpen: value('--devsite-folders') !== 'closed',337+ openFolders: new Set(names('--devsite-open-folders')),338+ folderOrder: names('--devsite-folder-order'),339+ };340+}341+342+/// Named folders come first in the requested order. A name that does not exist343+/// for this viewer is ignored, and unlisted visible folders retain the order in344+/// which they first appeared.345+function orderedFolders(folders, order) {346+ const result = [];347+ const placed = new Set();348+ for (const name of order) {349+ if (folders.has(name)) {350+ result.push([name, folders.get(name)]);351+ placed.add(name);352+ }353+ }354+ for (const entry of folders) {355+ if (!placed.has(entry[0])) result.push(entry);356+ }357+ return result;358+}359+360+/// One semantic fold. Layout declarations choose only its initial state; once361+/// rendered, the reader owns the ordinary `<details>` interaction.362+function folder(name, items, layout) {316363 const details = document.createElement('details');317364 details.className = 'folder';318- details.open = true;365+ details.open = layout.foldersOpen || layout.openFolders.has(name);319366 details.innerHTML =320367 `<summary>${esc(name)} <small>${items.length}</small></summary>`;321368 details.append(entryList(items));
@@ -325,6 +372,7 @@ function folder(name, items) {
325372 function renderProfile(profile) {326373 setPageTitle(`@${profile.handle}`);327374 applyTheme(profile.handle, profile.theme);375+ const layout = profileLayout(profile.theme);328376 main.innerHTML = '';329377 330378 const article = document.createElement('article');
@@ -348,8 +396,8 @@ function renderProfile(profile) {
348396 const loose = entries.filter((item) => !item.folder);349397 if (loose.length) article.append(entryList(loose));350398 351- for (const [name, items] of foldersOf(entries)) {352- article.append(folder(name, items));399+ for (const [name, items] of orderedFolders(foldersOf(entries), layout.folderOrder)) {400+ article.append(folder(name, items, layout));353401 }354402 } else {355403 const empty = document.createElement('p');